mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
feat/installer-v2
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6acf0dea0f |
fix(cron): scope the launch pre-flight to launcher CLIs, not every mode
CI caught three cron-service failures. Both are mine, from converting cron's per-mode ladders to capability reads without checking what each ladder's scope actually was. **The pre-flight.** cron only ever pre-flighted `deepseek` — dsh is a profile LAUNCHER, so "installed" is not "runnable" and a bare `dsh` can boot a profile that cannot drive a pane. I replaced that with an unscoped `resolveCliLaunchError(mode)`, which pre-flights EVERY mode, so a claude cron job on a box with no claude binary now failed with "Claude CLI not found" instead of reaching tmux-manager's own throw. Three tests assert the latter. It is now gated on `discovery.launcherProfile !== undefined`, which is byte-identical to the `mode === 'deepseek'` check it replaces and generalises to the next launcher. The equivalent HTTP-route conversion was already scoped (to `capabilities.external`, matching what that route has always pre-flighted); I simply failed to carry the same reasoning across. **The model.** cron's ladder was `mode !== 'shell' && mode !== 'deepseek'`, and I read it as `capabilities.model.source === 'claude-settings-file'` — which is the HTTP route's question, not cron's. There, every external CLI reads its model from its own config object earlier in the chain, so only claude reaches the global default; cron has no such config, so the same expression silently narrowed the default model from eight modes to one. Now `!== 'none'`, which is exactly the two entries the ladder excluded. Not caught by a test — found by re-deriving each ladder's scope after the first failure. Also names a fourth deliberate behaviour change in the changeset, found while tracing these: `session.ts` carried a hand-written list of modes with no direct-PTY fallback and OMP was missing from it, though CLAUDE.md's own text says "all eight require tmux". `requiresMux` comes off the entry now, so an omp session whose mux creation fails refuses instead of silently starting outside tmux. Verified by diffing failing tests BY NAME against an upstream/master baseline, rather than by file as before — which is how the regression slipped through: the three new failures landed inside a file already failing for unrelated Windows-path reasons, and the aggregate count happened to collide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ |
||
|
|
4830e662f9 |
refactor(cli-registry): make CLI backends data instead of per-mode branching
Every run mode is now a `CliEntry` in `src/config/cli-registry/` — discovery (search dirs, version + identity probes), the launch argv template, env handling, the `capabilities` flags that replace per-CLI branching, and the `overlays` that back the remote/docker pane commands. Code that used to ask "which CLI is this?" reads the entry instead. Behaviour is unchanged. `test/cli-registry-spawn-golden.test.ts` pins every spawn command as a literal string, captured from the hand-written builders before they were deleted, and `test/location-overlay-commands.test.ts` does the same for all 20 remote and in-container pane commands. Config can never contain shell text: an entry declares typed argv tokens, literals are validated against a safe-word pattern at LOAD time (a bad literal rejects the whole entry — a silently dropped `--no-approve` is not cosmetic), and values resolve through patterns NAMED in code, so a user `clis.json` cannot widen its own validation. `~/.codeman/clis.json` overrides any entry, read-only in this release. OMP is included as a registry entry rather than a tenth hand-written builder, so `buildOmpCommand()`, the omp availability pre-flight, the omp arm of `buildPathExport()` and the omp entries in the truecolor/NO_COLOR, alt-screen and doctor ladders all drop out. Guard rails: - `test/cli-registry-no-id-branching.test.ts` fails the build if per-CLI-id branching reappears outside `stock.ts`, in any of its four shapes (`===`, `!==`, `switch`/`case`, `includes`) — an `===`-only version would miss the negated forms, which is how 36 of them survived an earlier pass. Every allowlisted branch carries its reason. - `external`, `hooks` and `altScreen` stay three INDEPENDENT capabilities; deriving one from another shipped the `until=stop`-hangs-on-shell bug. - `param` is two namespaces. `launch.params` keys, `configSetenv.fromParam` and `privilegedParams[].param` all name a LAUNCH param; the legacy `<Mode>Config` wire field is separate, bridged only by `legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. - Registry data resolves AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks). A module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. - Six fields are annotated DECLARED-FOR-LATER and read by nothing (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/ `keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed rather than measured. A test pins the list so it cannot quietly grow. Three user-visible changes, all deliberate and named: - `probeDockerCliVersion()` derives the in-container binary from the registry rather than assuming it equals the mode name (`antigravity` runs `agy`). - The remote CLI version probe now covers grok and deepseek, which the hardcoded map it replaces omitted while its own comment said the rule was "every mode except shell". - `codeman doctor`'s CLI rows are generated from the entries, so Claude's install hint is the install command rather than a docs URL, five CLIs gain hints they never had, and the row order follows the catalog. Also hardened along the way: `sessionModeSchema()` is bounded at 24 chars (matching the `cliId` pattern) before its failure message quotes the value back, and `deepMerge` skips `__proto__`/`constructor`/`prototype` when reading the hand-editable `clis.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ |
||
|
|
a628737d1f |
fix(deepseek): review-driven hardening across the harness integration
Fifteen review findings on the dsh mode, the serious ones first: - Multi-user: DEEPSEEK_BASE_URL joins the owner-clamped env keys. _configureDeepSeek() forwards the SERVER's own DEEPSEEK_API_KEY into every dsh pane and applyEnvOverrides() lands after it, so a non-granted owner who could redirect the base URL would have the operator's key sent as a bearer credential to a host of their choosing. - Wait registry: until=stop/blocked is refused on docker and remote-SSH dsh sessions (new deepSeekBridgeUnreachable fact in sessionHookOptions). The HERDR triple is set via LOCAL tmux setenv, which crosses neither docker exec nor ssh, so such a session can never post a hook event and the wait burned its whole timeout on every turn. - Approvals: a dsh item is an ALERT, not an answerable card. The answer route refuses (the '1'/Esc keystrokes are Claude-dialog-shaped and the option parser cannot read a third-party TUI's frames, so an answer was a blind keystroke into a foreign composer), and the push notification carries no Approve/Deny actions for dsh sessions. - Status shim (v3): --seq is forwarded and the server drops stale retried reports inside a 60s window (the TUI retries with backoff, so a retried 'working' could land after 'blocked' and resolve an approval whose dialog was still on screen); 4xx responses exit 0 instead of retrying, so one misconfigured session cannot feed the auth rate-limit bucket until the hook endpoint 429s for the whole instance. - Web-UI server: concurrent starts are serialized through a lock (two racing POSTs used to pick the same port and orphan the winner), and the readiness poll / timeout paths only clear or stop the singleton while it is still theirs. First click actually opens the tab now (refreshWebviews, not the nonexistent loadWebviews). DELETE /api/deepseek/web requires the privileged grant in multi-user mode. - Cron: deepseek jobs run the same two-part launch gate as the HTTP create paths (impl moved into the resolver so all three share it) and no longer stamp a Claude default model on the session. - Parity sweeps: quick-start's docker branch rejects deepSeekConfig like the remote branch; the Ralph auto-enable list gained deepseek; HookEventType gained agent_working; the phone overview run menu filters managed webview records like the desktop menu. - install.sh: the dsh identity probe closes stdin (under curl|bash a child that reads stdin eats the rest of the script), bounds the exec with timeout where available, and is memoized to one scan per install. - Welcome screen: .welcome-btn-deepseek styled in the #4d6bfe brand identity (it rendered as an unstyled UA-grey button); stale markup comment about the web shortcut rewritten; clamp docs updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4cda150493 |
feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode
Adds `mode: 'deepseek'` alongside claude/shell/opencode/codex/gemini/ antigravity/pi/grok, plus a shortcut that opens the harness's own browser UI as a Codeman web tab. DeepSeek is wired unlike its siblings in three ways, each of which is the reason for a design decision rather than an accident: 1. The agent is a PROFILE, not the binary. `dsh` is a launcher over $DSH_HOME/profiles/<name>, and DeepSeek ships only `web`, `headless` and `base` -- the interactive terminal front door is always a third-party plugin. So availability is two questions: `isDeepSeekAvailable()` (binary) and `isDeepSeekRunnable()` (binary AND a pane-capable profile). The Run button gates on the latter, because reporting only the binary would spawn a pane that dies on arrival. When the binary is present but no profile is, the run menu offers to install one (POST /api/deepseek/install-profile). 2. The permission switch is an env var, not a flag. The harness has no command-line permission option; its sandbox/approval rows read DSH_PERMISSION_MODE (read-only / workspace-write / danger-full-access). Exported via `tmux setenv`, never on the spawn line. Absent = the harness's own workspace-write, which still asks, so the multi-user clamp is the only-if-sent branch and clamps to workspace-write, never read-only. 3. It is the only non-claude mode that passes hooksAvailableForMode(), and it earned that. The terminal front door reports idle/working/blocked to a supervising process over a generic env-gated contract; a generated shim (deepseek-status-shim.ts) makes Codeman that supervisor and forwards each report to /api/hook-event as stop / agent_working / permission_prompt. So a dsh session gets definitive respawn triggers, real wait-endpoint signals and real Approvals Inbox items instead of output-stabilization guesswork. `agent_working` is new (157th SSE constant) and joins APPROVAL_RESOLVING_EVENTS so a dialog answered in the terminal clears its alert at once. The resolver needs the strictest identity probe of the family: `dsh` is not merely a squattable npm name, Debian ships an unrelated `dsh` (dancer's shell), so `dsh --help` must print the harness's own banner before a candidate is handed a spawn line. Model is deliberately not a session field -- it is a composition entry in the profile's config tree. Env allowlist gains DSH_* and DEEPSEEK_* only; provider keys named by a settings-file `apiKeyEnv` stay out, which is pi's 34-provider-key problem in a new shape. Verified live against dsh 0.1.1-rc.2 and @deepseek-harness-tui/dsh-tui: the status endpoint's two-part answer, the no-profile refusal, the profile bootstrap, a real session whose pane runs `dsh --profile dsh-tui` with the permission mode injected via setenv, and the full status bridge -- a send-and-wait returned signal "stop" from a real turn, and blocked/working created and cleared an Approvals Inbox item. Docs: docs/deepseek-integration.md (guide), docs/deepseek-integration-plan.md (decisions + honest gaps). Tests: test/deepseek-mode.test.ts, test/deepseek-cli-resolver.test.ts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c173ae0264 |
Merge remote-tracking branch 'origin/master' into worktree-grok-mode
# Conflicts: # src/web/public/app.js |
||
|
|
74194e4fc0 | feat(tabs): COD-359 add owner-scoped tab layouts | ||
|
|
3f8c8e99d1 |
feat(grok): add Grok Build (xAI) as a seventh CLI run mode
SessionMode gains 'grok', a first-class backend alongside Claude Code,
shell, OpenCode, Codex, Gemini, Antigravity and Pi: its own PTY, tmux
session, charcoal tab identity ('gk' badge), welcome button, run-mode
entry, cron agentType, Docker and remote-SSH command defaults, and
clone-repo Brain option. Flag surface verified live against grok 1.0.5.
Grok mixes two existing shapes and the wiring follows from that:
- Codex-shaped on permissions: the bypass switch is GrokConfig.alwaysApprove
(--always-approve, grok's bypassPermissions mode; config-level deny rules
still apply on top). The Run button sends it true, like runAntigravity(),
and clampExternalCliBypassForOwner() puts grok in the only-if-sent branch:
a bare grok spawn is grok's own ask-mode default, which is already safe,
so only a sent config needs the flag forced off. Cron needs nothing for
the same reason.
- OpenCode-shaped on rendering: grok is a fullscreen alternate-screen TUI
with mouse support, so it stays OUT of isAltScreenStripMode() and lands
on the narrow tmux-attach strip and the 'buffer' local-echo fallthrough
(unmeasured against an authenticated composer; documented fallback is the
'off' branch).
- Pi-shaped on resolution: 'grok' has npm squatters (@vibe-kit/grok-cli
also installs a grok bin), so grok-cli-resolver.ts version-probes every
candidate (grok --version, killSignal SIGKILL, VITEST-gated) and
GET /api/grok/status surfaces path AND version; GROK_VERSION_REGEX is
shared with the dependency registry so doctor and run mode cannot drift.
Env allowlist gains GROK_* plus the XAI_* vendor namespace (XAI_API_KEY is
grok's documented headless auth var), the same narrow-vendor reasoning as
GOOGLE_* for gemini. Resume is id-regexed on purpose: grok's own --resume
also matches session titles, which are arbitrary user strings that must
never reach the bash -c spawn line.
Docker: grok is not on npm, so the agent image installs it in its own step
(xAI's installer has no --dir override; the binary is copied to
/usr/local/bin and root's ~/.grok dropped in the same layer), and
credentials are seeded per-file (auth.json, config.toml, pager.toml; the
dir also holds sessions/, memory/ and the ~160MB binary). Remote SSH routes
through the login-shell wrapper like the other agent CLIs.
Verified end to end on an isolated CODEMAN_INSTANCE with grok 1.0.5
installed: /api/grok/status resolves and reports the probed version,
quick-start spawns a pane whose command line ends in 'grok
--always-approve', the real TUI renders (OAuth device screen on an
unauthenticated box), and grokConfig round-trips through state.json.
Docs: docs/grok-integration.md (user guide) + docs/grok-integration-plan.md
(decisions, verification record, follow-ups).
Tests: test/grok-mode.test.ts, test/grok-cli-resolver.test.ts, plus
extended clamp/system-routes/render-index-html/run-mode-ui/mobile-overview/
local-echo-gating coverage. npm test (the CI gate) green: 5910 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
f44d597450 |
review fixes: every claude create path routes through the workspace-hooks decision
Post-#304 follow-ups. The install-vs-refresh decision (workspaceHooksEnabled, default ON) moved from a session-routes-local helper into hooks-config.ts as applyWorkspaceHooks(workspace, install?), and the claude session-create sites that bypassed it now go through it: cron job fires (cron-service), legacy scheduled-run iterations (runScheduledLoop), and the plan-orchestrator research and planner one-shots. A cron or scheduled run firing in a linked case that never had an interactive session ran hook-blind (no stop for completion detection, no tab alert on a blocking dialog). The shared core also carries the two guards every caller needs: a workspace that no longer exists is skipped (ensureCodemanHooks mkdir -p's, so the boot recovery sweep used to resurrect a deleted repo as an empty tree holding only .claude/settings.local.json), and all errors are swallowed since a create must never fail on hooks. Route handlers keep resolving the setting through their ConfigPort and pass it in; non-route callers omit it and the core reads settings.json itself (absent key or unreadable file = ON). Two adjacent gates tightened in session-routes: - the docker quick-start hooks branch excluded the five external CLIs but let `shell` through, contradicting its own rule that only claude reads .claude hooks; it is now gated on mode === 'claude' - the statusLine exporter call in POST /api/sessions got the same !remote && body.workingDir guard the hooks call got in |
||
|
|
f4dcfbe6ca |
fix(pi): close four mode-list gaps in the pi run mode
Review follow-ups on #282. All four are the same failure shape: a list that enumerates run modes, missed by the sweep that added 'pi'. 1. Cron ignored pi's project-trust clamp. The PR widened CronJobBaseSchema's agentType to accept 'pi' but not the matching clamp beside gemini's, so a non-granted multi-user owner's cron pi job spawned bare `pi` (pi's own defaultProjectTrust, an interactive prompt they can answer "yes" to, which loads and EXECUTES repo-local .pi/extensions TypeScript) while the same user's UI/API launch was forced to --no-approve. The clamp is now a pure exported helper, clampCronExternalCliConfigs(), so both it and gemini's previously untested materialization are pinned. 2. POST /api/sessions/:id/interactive auto-enabled the Ralph tracker for pi: its denylist covered opencode/codex/gemini/antigravity only. The tracker is never fed for an external CLI (_processExpensiveParsers returns early), so a pi session reported ralphEnabled and Ralph UI state no sibling backend shows. 3. REMOTE_CLI_BIN had no pi entry, so buildRemoteCliVersionProbeCommand() returned null and Session.cliVersion stayed blank for every remote-SSH pi session, even though the PR wired the remote launch command and the per-mode override schema field. 4. The desktop home rail's badge map had no pi entry, and its lookup falls back to '', which is what claude renders. A pi session read as Claude there while the tab strip and phone overview badged it correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ccb3afc9ee |
fix(multiuser): close cross-user web-layer scoping holes found in review
The opt-in multi-user feature's only enforcement is web-layer scoping (all sessions share one OS account). An adversarial review found 8 critical + 7 high cross-user holes that defeated it, plus mediums; all fixed here. Single-user (flag-off) behavior stays byte-identical apart from documented consistency deltas. Ownership / confinement: - DELETE /api/sessions (bulk) + /:id now owner-scope / findSessionOrFail - quick-start, cron (create+fire), scheduled runs confine workingDir to the owner's space; case link/docker-link/docker-import confine the host path - resolveCasePath no longer resolves linked cases for non-admins; foreign remote/docker cases are skipped (fall through to the caller's own local case) - history, subagents/workflows, mux-sessions, orchestrator, cron run-history, away-digest, and remote/docker host reads are owner- or admin-scoped Permission policy (section 6.3): - non-granted users are downgraded at every spawn site incl. legacy /api/scheduled, PlanOrchestrator one-shots, remote launch, and the cron-fire gemini/codex bypass switches; resolveClaudeModeForUsername now fails closed Auth / store: - verify-first login throttle (a correct password is never locked out), /ws terminal subject to the change-password lockbox, cookie fast-path re-validates identity live, role/grant changes revoke sessions, admin delete runs the last-admin guard before any teardown - users.json: distinguish missing (ENOENT) from corrupt/unreadable so a bad read can't overwrite all accounts; unique per-process temp write path Event streams: - debounced session:updated + batched task:updated, clipboard, and push notifications route by owner (fail closed); getLightState hides machine-wide globalStats from non-admins Tests: two suites updated to assert the fixed (secure) behavior. tsc, eslint, and test:ci all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
453605a58f |
feat(multiuser): phase 3, ownership threading + scoping
Threads per-user ownership through sessions, cases, cron, and the permission policy. All scoping is a no-op in single-user mode (isMultiUserMode() guards). Sessions - Session.owner stamped at every create path from req.authUser / job.owner: POST /api/sessions, /api/run, /api/quick-start, ralph start, cron launch, plan generation. Round-trips through recovery (MuxSession.owner mirror, read muxSession.owner ?? savedState?.owner) and the mux layer. - findSessionOrFail(ctx, id, req) now does a NOT_FOUND owner check (never 403, so other users' session existence is not leaked); wired at ~50 call sites. - List endpoints filtered by owner: GET /api/sessions, /api/sessions/unified (live+persisted+lifecycle scoped, host-wide transcripts admin-only), cron jobs. Permission policy (section 6.3) - resolveClaudeModeForUsername wraps getClaudeModeConfig at every spawn site so a non-granted user is forced to --permission-mode auto (bypass -> auto), including recovery (or a reboot would un-downgrade). buildPromptArgs now respects the session's claudeMode, closing the one-shot (runPrompt) bypass hole. - Shell mode and cron launchCommand require canBypassPermissions: 403 at POST /api/sessions, /api/quick-start create, cron job create, AND cron fire time (re-checked against the owner's current grant). Cases - resolveCasesDir(user): per-user ~/codeman-users/<name>/cases in multi-user, the shared ~/codeman-cases otherwise. All case CRUD + ralph + plan + quick-start resolve through it. resolveCasePath is owner-aware. - GET /api/cases scoped per user (own folders; legacy linked cases admin-only; remote/docker cases owner-filtered). RemoteCase/DockerCase gain owner, stamped at link/quickcreate/import. - Remote + Docker host CRUD is admin-only. - Non-admin workingDir confinement (the linchpin): realpath must resolve inside the user's space, enforced at POST /api/sessions and /api/run BEFORE any disk write. Limits - sessionCapacityState / sessionCapacityMessage centralize the global + per-user cap (CODEMAN_MAX_SESSIONS_PER_USER, default global/2), replacing the 6 copy-pasted MAX_CONCURRENT_SESSIONS checks. Tests: test/ownership-scoping.test.ts (case isolation, host-CRUD gate, workingDir + shell gates, and the scoping helpers). Deferred to phase 4: WS owner gate, SSE fan-out filtering, file-route preview/thumbnail helper scoping, push routing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
84ab4ff07b |
fix(review): harden cron security, session lifecycle, skip policy (PR #141)
- Reject multi-line prompts end-to-end: schema refines on promptText/ launchCommand, runtime check in resolvePrompt (prompt-file content; trailing newlines tolerated), matching cron-ui form validation — delivery is single-line only, so multi-line was silently corrupted (typed mode fused lines, paste mode submitted partials) - Close the workingDir confinement bypass (arbitrary server-side file read, e.g. workingDir=/proc + /proc/self/environ): realpath-resolve workingDir before the containment check, reject '/' and blocked/pseudo-fs trees (/proc, /sys, /dev + the attachment-guard blocklist) at fire time AND at job create/update (workingDir must exist and be a directory) - Session lifecycle: new per-job autoClosePreviousSession (default true, recurring schedules only; ignored for 'once') — the previous run's still-open session is closed via the normal cleanupSession path when the next run fires; UI switch added; 50-session cap math documented in docs/cron-guide.md §8 - skip_if_same_agent_running: count only live sessions (exclude stopped/error dead tabs), exclude sessions created by this job's own runs (fixes the fire-once-then-skip-forever self-deadlock), and a skipped 'once' job stays armed and retries next tick instead of being consumed; liveness filter mirrored in cron-ui _countActiveAgents - Wire launchCommand (was accepted+documented but dead): shell mode sends it via writeViaMux as the first input line after startShell readiness (single-line, schema-enforced); form field shown for shell agent type - Record delivery failures: a false writeViaMux result now fails the run instead of recording a false 'prompt_sent' - Cap saved jobs at MAX_CRON_JOBS (100) to bound state.json growth - Surface field-specific schema messages (drop parseBody custom errorMessage on cron create/update) - Tests: workingDir create/update validation, /proc bypass regression, single-line enforcement (schema+runtime+trailing-newline tolerance), live/own-session skip filtering, once-skip re-arm, auto-close on/off/once, job-count cap Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d9c2c6420d |
fix(cron): harden cron fixes against adversarial-review findings
Two blind adversarial reviewers found real holes in the prior cron commits:
SECURITY (was CRITICAL): the prompt-file guard was blocklist-only by default,
so promptFilePath:/proc/self/environ leaked the SERVER PROCESS's entire
environment (every secret) into the agent session, and /dev/zero or a FIFO
caused an unbounded readFile → OOM/hang DoS. A denylist is the wrong posture
for an exfil-into-LLM sink. resolveSafePromptPath now:
- confines the realpath-resolved file to the job's working dir (ALLOWLIST) —
closes /proc, /dev, other homes, modern cloud-cred paths, and symlink escapes
- requires a regular file (rejects dirs/FIFOs/char devices)
- caps the read at MAX_PROMPT_FILE_BYTES (1 MiB)
- keeps the /etc,/root,secrets blocklist as defense-in-depth
LOGIC:
- once-rearm (was MED, defeated in prod): the edit UI round-trips the full
job, so the field-PRESENCE re-arm check always fired → a renamed fired
once-job could be resurrected via edit→re-enable. Now compares schedule
VALUES; an unchanged schedule never re-arms.
- skipped-run history (was HIGH): recording a skip every tick was unbounded
state.json growth. Now coalesces consecutive skips (one record per streak)
and prunes global run history to MAX_CRON_RUN_HISTORY (500), covering the
launch path too.
- skip bookkeeping (was MED): a skip no longer advances lastRunAt (nothing
ran); lastStatus still reflects 'skipped'.
Regression tests added/updated (43 pass): /proc/self/environ + outside-workspace
+ symlink-escape + non-regular + oversized all blocked, in-workspace file
passes; UI-path once resurrection blocked; consecutive skips coalesce to one
record; skip leaves lastRunAt null.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
|
||
|
|
6082bceee6 |
fix(cron): close three MED cron-job defects
1. once-rearm on edit: editing any field of a finished one-time job reset completedOnce, silently resurrecting it. Now only a SCHEDULE edit (scheduleType/runAt/interval/daily/weekly) re-arms a completed once job; cosmetic edits (rename/notes) leave completedOnce intact. 2. update-validation gap: CronJobUpdateSchema = .partial() drops the cross-field superRefine, so a PUT switching scheduleType without its dependent field produced a dead enabled job (nextRunAt:null). updateJob now re-validates the MERGED job against the full CronJobSchema and throws 400 on inconsistency, leaving the stored job untouched. 3. concurrency-skip silent starvation: skip_if_same_agent_running advanced the schedule but wrote no run record, so a perpetually-skipped job had empty history. Now records a 'skipped' run (new CronJobRunStatus) + lastStatus. Tests updated/added in cron-service.test.ts (37 pass): once non-schedule edit preserves completedOnce, schedule edit re-arms, inconsistent partial update is rejected with the stored job untouched, and the skip path records a skipped run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU |
||
|
|
40e26c5422 |
fix(cron): confine cron prompt-file reads to block sensitive paths
A cron job's promptFilePath is user-supplied via the API and was read with an unconfined readFile of any absolute path, so a hostile job config could exfil arbitrary host files (e.g. /etc/passwd, SSH keys) into a Claude session. Guard the read in resolvePrompt by mirroring the attachment-serving guard (resolveServableAttachmentPath in file-routes): realpath-resolve the path, then reject via the shared blocklist (/etc, /root, secret locations) plus the optional workspace-confinement toggle before reading. Regression tests in cron-service.test.ts: blocks /etc/passwd (the live repro) and /root/*, fails cleanly on a missing file, and still allows an ordinary prompt file outside the blocklist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU |
||
|
|
9feaa0d6e5 |
refactor(cron): rename scheduler feature to cron
Rename the recurring-jobs feature scheduler->cron to disambiguate from the legacy ScheduledRun system (/api/scheduled), which is left untouched: - ScheduledJob->CronJob, SchedulerService->CronService - /api/scheduler/jobs -> /api/cron/jobs; SSE scheduler:* -> cron:* - state keys cronJobs/cronJobRuns - files moved to src/cron/, cron-routes.ts, cron-port.ts, types/cron.ts - frontend cron-ui.js, #cronModal, menu "Cron" - docs moved to docs/cron-discovery.md + docs/cron-build-brief.md, README guides - new tests: cron-service.test.ts, cron-time.test.ts Green: tsc, lint, frontend-syntax, format, 30 cron + 9 legacy scheduled-runs tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU |