A cron job in "Paste (direct)" input mode wrote `<text>\r` into the pane
in one piece. Claude Code (measured on 2.1.283) takes a burst of about a
hundred characters as a paste, so the `\r` landed as a newline and the
prompt sat unsent on the composer while the run reported `prompt_sent`.
Delivery now lives in `deliverCronPrompt()`. Paste mode writes the text
raw, waits CRON_PASTE_ENTER_DELAY_MS (300 ms), sends `\r` as a separate
write down the same PTY (so it cannot overtake the text), and arms the
session's composer check through the new public
`Session.verifySubmitted()`, which re-presses Enter while the prompt is
still visibly unsent. A session with nothing to write to now fails the
run instead of reporting the prompt as sent. Typed mode is unchanged.
Verified on an isolated instance: a paste-mode job with a 104-character
prompt submitted on the first Enter and Claude answered.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
- 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>
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
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
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
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