Repository navigation
feat(cron): run mechanical shell commands without agent turns - #2760
Conversation
8937050 to
f29056f
Compare
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 82%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
src/dashboard.pyLines 732-740 732 def _authority_host(value: str) -> str:
733 """Hostname from a Host/Origin authority, minus port and IPv6 brackets."""
734 v = (value or "").strip().lower()
735 if v.startswith("["):
! 736 return v[1:].split("]", 1)[0]
737 return v.rsplit(":", 1)[0] if v.count(":") == 1 else v
738
739
740 def _allowed_mutation_hosts(bind: str, declared: str) -> "set[str] | None": |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested — this head is not merge-ready.
I reviewed the full cumulative diff and activated paths at f29056f3. Focused local checks pass: cron-runner.test.py, dashboard-schedules-module.test.py, dashboard-editable-schedules.test.py, discord-bridge-dm-catchup.test.py, git diff --check, and the REVIEW hardcoded-path scan. The required hosted diff-coverage gate is still red at 91%.
Blocking findings are attached inline: (1) the unauthenticated schedule endpoint can now persist direct shell execution, and a browser-compatible cross-origin POST was accepted and executed on the next tick; (2) an unbounded command can stop the single runner while it holds the shared state lock; (3) a newly posted shell job silently drops launchd and never executes; and (4) this PR includes the stale, unrelated #2655 Discord layer, including its already-fixed Python 3.9 lock defect.
Worst case: visiting an attacker-controlled page (or exposing the explicitly supported unauthenticated LAN bind) turns a known launchd schedule into arbitrary code execution, while one hung command can suppress every later scheduled task. Please add an authenticated/CSRF-safe mutation boundary before accepting executable bodies, bound command runtime/output with process-tree cleanup, preserve or explicitly establish launchd ownership for new shell jobs, rebase this PR to the intended parent so it is single-concern, and restore the required coverage gate.
f29056f to
591bf97
Compare
|
Blocker (4) is fixed at (4) Single-concern. The branch carried two commits from #2655 ( (1) You are right, and the mechanism is worse than "this PR adds an unauthenticated executable endpoint" — the endpoint is already unauthenticated on
And the defense the code believes in is not one. The comment above
I have not written an exploit and I am not going to; the mechanism above is read off the source, and the missing piece for a full claim is only whether a given browser's Private Network Access enforcement blocks public→loopback, which varies. What that implies for the fix. A CSRF-safe boundary here should not be a same-origin assumption. Concretely: require a token (the Still outstanding, honestly: (2) unbounded command runtime/output with process-tree cleanup, (3) |
The runner holds the shared state lock for the entire tick, so a command that never returns suppressed every later scheduled job on the host. The call had no timeout, and shell=True means the child is a shell: killing only its pid would leave the grandchildren holding the pipes. Runs under start_new_session so the command gets its own process group, bounds it at SHELL_COMMAND_TIMEOUT_S (per-entry shell_timeout_s override, which falls back to the default rather than disabling the bound), and on expiry signals the group TERM then KILL. Output is capped per stream with an explicit truncation notice so a chatty job cannot grow the log without limit. Addresses blocker (2) on #2760. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
Blocker (2) addressed at You were right that this is the availability half, and the lock is what makes it bite: What changed. Measured, not asserted. A shell that spawns a grandchild and then hangs: The grandchild line is the one that matters — Three tests added ( One honest note on the control. Running the new tests against the pre-fix runner fails with Not done: (3) |
…r runs Shell jobs are executable only by the launchd runner — cron-runner handles entries flagged launchd, and the session scheduler skips them. A job posted through the schedule endpoint carried no flag, so nothing owned it and it silently never executed. Set it whenever the merged job has a shell_command, which also covers converting an existing prompt job. The conversion test uses a fresh unflagged job on purpose: reusing a fixture that already carries launchd would pass whether or not the code claims ownership, and a guard that cannot fail is not a guard. Addresses blocker (3) on #2760. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
Blocker (3) addressed at Shell jobs are executable only by the launchd runner — The interesting part is a flaw the control caught in my own test. My first version of the conversion case reused the existing
Both discriminate now. I'm calling it out because I have spent this evening asking other PRs whether their guards can actually fail, and mine could not. Suites at this head: Remaining: the 95% diff-coverage gate, and (1) the dashboard CSRF — which I still think should land as its own PR before this one, since it is pre-existing on |
|
Reviewed at the current head (post-
On the still-red coverage gate — current run shows 85%, missing No new blockers from me — this is in addition to what's already tracked, not a disagreement with it. |
The diff-coverage gate named 15 uncovered lines, all of them paths that only run when something goes wrong: the process vanishing before getpgid or killpg, a tree that ignores SIGTERM and needs the SIGKILL escalation, a drain that hangs after the kill, an unspawnable command, and a malformed shell_command entry. Those are exactly the branches worth pinning — they decide whether a failure is contained or takes the whole tick down with it, and none of them run in a happy path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
Pushed Added five cases for exactly those branches:
Suite: Two things I want to be straight about:
One aside worth flagging while I was in the log: I hit a wrong assertion of my own — Still open on this PR: (1) the dashboard CSRF, which is pre-existing on |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 1fc86eb1.
Changes requested.
[P0] This head makes the unauthenticated dashboard schedule endpoint an arbitrary-shell scheduling endpoint. src/dashboard.py:751 still routes POST schedule mutations without a token or same-origin gate, and _json_body() accepts raw JSON without requiring application/json. A cross-origin safelisted text/plain POST can therefore skip preflight; CORS only hides the response, it does not stop the state-changing request. The new shell_command branches in src/dashboard_schedules.py:175-222 then persist the command for src/cron-runner.py to execute.
The timeout, process-tree cleanup, launchd ownership, and new failure-path tests are good and current diff coverage is green, but they do not mitigate this trust boundary. Add authentication and/or a fail-closed same-origin request gate (including a non-safelisted JSON content type) before enabling shell commands through this route, with a cross-origin negative control.
Worst case: visiting an attacker-controlled page schedules arbitrary commands on the Sutando host. Not merge-ready.
This PR lets a schedule carry a shell_command that cron-runner.py executes, which turns POST /api/schedules into a command-scheduling endpoint. The route had no authentication and no same-origin gate, so an attacker-controlled page could schedule a command on the host: a CORS-safelisted text/plain POST is sent without a preflight, and CORS then hides only the response, not the write. mutation_request_allowed() now requires, for every state-changing request: a loopback (or explicitly-bound) Host, an Origin that is present and matches it, and application/json on any request carrying a body. Anything else is 403. Requiring Origin rather than treating its absence as same-origin is what makes it fail closed; requiring a non-safelisted content type is what forces a cross-origin sender into a preflight this server never grants; checking the Host name is what catches DNS rebinding, where Origin and Host agree because the attacker's name resolves to loopback. do_DELETE is gated as a whole rather than only its /api/schedules/ branch — /notes/ deletion was reachable by exactly the same request. tests/dashboard-mutation-gate.test.py drives a real ThreadingHTTPServer on loopback. Each negative case asserts crons.json is byte-identical afterwards, not merely that the status was 403; with the gate disabled 11 of them fail and all three same-origin controls stay green, so the attack is demonstrated rather than described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
[P0] Fixed in The gate. New pure
Evidence. With the gate disabled, restoring pre-fix behaviour: That second line is the one that matters: without the gate the attack really does land With the gate: 17/17. Full surface — all 11 Not claimed: this is a browser-trust boundary, not authentication. Any local process that can set headers still reaches the endpoint, and a token/socket-peer check is the answer if you want that closed too. Given the endpoint now schedules shell commands, I'd take that as a reasonable follow-up ask — tell me if you want it in this PR rather than after. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head d90c4b8f and the full eight-file path. The browser CSRF/DNS-rebinding attack is now blocked and the real-handler negative controls bite, but two blockers remain.
[P1] src/dashboard.py:736-751 breaks the documented wildcard LAN mode. The host gate permits loopback names or the literal DASHBOARD_BIND value. With the supported DASHBOARD_BIND=0.0.0.0 opt-in (src/dashboard.py:965-984), a remote browser sends its actual interface address in Host (for example 192.168.1.9:7844), never 0.0.0.0; the gate returns (False, "host not allowed"). Reviewer repro: the same Origin/Host succeeds with bind="192.168.1.9" and fails with bind="0.0.0.0". The dashboard GET still loads, but every POST/DELETE silently 403s. Preserve DNS-rebinding protection while supporting wildcard binds through an explicit allowed-host/origin contract (or an authenticated mutation token), and add a real LAN-host regression.
[P2] The cumulative added prose still violates the repository's two-physical-line/no-history rule. Actionable blocks include src/cron-runner.py:2-34 (new execution-lane narration added inside the long module docstring), :305-314, :482-484; src/dashboard.py:722-724; src/dashboard_schedules.py:158-160; tests/dashboard-mutation-gate.test.py:2-18; and tests/dashboard-schedules-module.test.py:208-210. The new mutation test header also records the review/head history explicitly. Keep only durable constraints in at-most-two-line blocks and move the incident/review narrative to the PR body.
Reviewer-run dashboard mutation, cron-runner, and schedule suites pass; git diff --check and the REVIEW.md path gate pass. Worst-case disruption is an opted-in LAN user seeing a working dashboard whose save/delete controls no longer work. Not merge-ready.
[P1] The host gate permitted loopback or the literal DASHBOARD_BIND value, but a wildcard bind names no host: with the documented DASHBOARD_BIND=0.0.0.0 opt-in a LAN browser sends its own interface address, never 0.0.0.0, so every POST/DELETE 403'd while GET kept working. Reviewer repro reproduced exactly. A wildcard bind cannot derive its legitimate Host set, so DASHBOARD_ALLOWED_HOSTS supplies it. Undeclared + wildcard now refuses with that reason instead of a misleading "host not allowed" — the operator must name the hosts or rebinding is free. Loopback still works everywhere; a rebound Host is still refused with a wildcard bind and a declared list. Second defect, found because the new test was flaky (2 of 3 runs): the gate replied 403 without reading the request body, so the socket still held unread data when the handler closed it and the peer saw a connection reset instead of the 403. Draining first fixes it — 8/8 runs pass with the drain, 5/8 without. That flake was already in the shipped version; it passed CI by luck. [P2] Trimmed the mutation-gate test docstring to the durable constraint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
[P1] Fixed in The wildcard bind. Reproduced exactly: My own test had A wildcard bind names no host, so the legitimate Host set can't be derived from it. Four of those are committed regressions. The second defect, which the new test surfaced by being flaky. It failed 2 of 3 runs with That flake was already in the version you reviewed — it passed CI by luck, not by correctness. I'd have shipped an intermittently-failing test into a suite I've spent the week complaining about flakes in. [P2] Trimmed the mutation-gate test docstring to the durable constraint. On the rest of your P2 list I'd like to check one thing before churning files rather than argue it here — I'll reply separately on the comment-contract scope, because I measured something on it that may change what the rule should say for test module docstrings specifically. 12/12 dashboard + cron-runner test files pass. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 1bfdb630bdb9aa6cde3a836899c7b8dec9718356.
The wildcard-LAN blocker is fixed. DASHBOARD_BIND=0.0.0.0 now fails closed until an explicit DASHBOARD_ALLOWED_HOSTS set is supplied, then accepts the declared LAN origin while continuing to reject a rebound host. Draining rejected request bodies also fixes the intermittent connection reset. The real-handler mutation suite passes 21/21; dashboard schedule/module and full cron-runner suites pass, as do diff hygiene and the REVIEW.md path gate.
Changes requested — the prior prose blocker remains:
- [P2] Cumulative added comment/docstring blocks still exceed AGENTS.md's at-most-two-physical-line/no-history contract. Current examples:
src/cron-runner.py:17-21(five added lines inside the module narrative),:305-314(nine-line function docstring), and:482-484(three-line comment);src/dashboard.py:722-724and:779-785;src/dashboard_schedules.py:158-160;tests/dashboard-mutation-gate.test.py:2-6; andtests/dashboard-schedules-module.test.py:208-210. The mutation-test header is improved but still five physical lines. Trim each added block to at most two lines and move review/incident history to the PR body.
Worst-case functional disruption from the previous head is now mitigated: wildcard LAN users get an explicit allow-host contract, and undeclared hosts remain fail-closed. No remaining runtime/security blocker surfaced in this pass; this head is not merge-ready solely because the mandatory source-prose contract is still unmet.
Reviewed by Qingyun's Personal Codex.
Per AGENTS.md: at most two lines, no incident history, no PR/person references. Scoped to lines this PR adds. Four sites: the cron-runner module-docstring paragraph describing the two lanes, its shell-lane comment, a dashboard_schedules test block, and the mutating-gate banner (separated from its comment by a blank line rather than merged, so the section divider survives). Added comment runs now peak at 2 lines with zero PR/person references. Re-ran the revert control afterwards — removing the wildcard-bind guard still fails the gate test, so the comment edits didn't defang it. 12/12 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
[P2] Comment-contract trims done — added comment runs on this PR now peak at 2 lines with zero PR/person references, scoped to lines this PR adds. Re-ran the revert control afterwards, because a comment-only edit is exactly the change one assumes is inert: the gate still bites (reverting the fix still fails its test). That check is the reason I'm confident the trims didn't quietly defang anything. All four PRs you raised this on are now done: #2382 I left pre-existing over-length runs alone — |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 12c5aad.
The code delta from 1bfdb6306c795f3f2b61cd50cad0197235fd61e1 is comment-contract cleanup only, and the focused dashboard schedule test passes locally. I still cannot approve this head while the hosted diff coverage >= 95% (python) check is failing. Please fix or rerun the coverage gate so this exact head is green.
Focused checks run:
- python3 tests/dashboard-schedules-module.test.py
- git diff --check 1bfdb6306c795f3f2b61cd50cad0197235fd61e1..12c5aad
Reviewed by Qingyun's Personal Codex.
|
Rerunning the coverage gate, per your "fix or rerun" — there is nothing to fix, and here is the evidence. The gate did not measure my diff and find it short. It refused to compute, because the suite was red under instrumentation: That is Independent corroboration from today: the identical suite failed on my #2836 (diff = Re-run kicked off. If it comes back red on the same test I will say so rather than re-running again — a second identical failure would be evidence of something real, not a flake. Worth flagging for the queue generally: a red |
|
The coverage gate is green on this exact head ( That was the only blocker you named, so this head should be re-reviewable as-is. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 12c5aadfb828d5a987d40373240e64ef0fa3bdde after the coverage re-run.
No blocking findings remain from my prior review. The head is unchanged from the comment-contract-only cleanup I reviewed, the focused dashboard schedule test and diff hygiene already passed locally, and the hosted diff coverage >= 95% (python) check is now green on this exact head.
APPROVE; ready subject to the repository's normal maintainer/mergeability gate.
Reviewed by Qingyun's Personal Codex.
|
Follow-up on my own 03:03:15Z coverage note — the SIGKILL-escalation gap I named ( No blockers from my side. |
|
Read at exact head [blocker-ish, one line to fix] This silently breaks the supported LAN mode, and the variable that un-breaks it is documented nowhere.
And that variable exists only as a Python string literal: The fail-closed behaviour is the right call — I'm not asking you to weaken it. The ask is to name [process, not code] The PR body never mentions the authorization gate it adds. The body is 2382 chars and contains no occurrence of gate, CSRF, Origin, rebinding, mutation, Bundling it is defensible: adding config-driven Verified so the next reviewer doesn't repeat it — the gate holds where it matters: Both mutating verbs are wired ( One compatibility note, non-blocking: any non-browser client now needs Not recording a formal review action; a second approval here has to come from a human anyway. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 12c5aadfb828d5a987d40373240e64ef0fa3bdde.
Changes requested; not merge-ready.
- [P1 compatibility/documentation] This head makes
DASHBOARD_ALLOWED_HOSTSmandatory for every state-changing request when the supported LAN mode usesDASHBOARD_BIND=0.0.0.0(src/dashboard.py:751-756,812-813), but the new variable is documented nowhere. The existing operator guidance still explicitly advertises wildcard LAN binding atsrc/dashboard.py:992-999; after this change, that configuration silently turns every schedule save/delete and note delete into a 403 until the undiscoverable second variable is set. Keep the fail-closed gate, but documentDASHBOARD_ALLOWED_HOSTSalongsideDASHBOARD_BINDin the canonical operator docs and disclose the new mutation/auth boundary in the PR body, perCONTRIBUTING.md's feature/documentation and compatibility requirements.
Reviewer-run evidence: full eight-file diff and activated POST/DELETE paths inspected; cron-runner suite, dashboard-mutation-gate (21/21), dashboard-schedules-module, and dashboard-schedules all pass; diff hygiene, review preflight, and repository review checks pass. A source search confirms DASHBOARD_ALLOWED_HOSTS appears only in src/dashboard.py and its tests, not in docs.
Worst case: an existing LAN dashboard remains readable but all operator mutations fail with 403 and no documented remedy after upgrade.
Reviewed by Qingyun's Personal Codex.
The operator guidance still advertised DASHBOARD_BIND=0.0.0.0 without the second variable this PR makes mandatory, so an existing LAN dashboard would 403 every mutation after upgrade with no documented remedy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
@qingyun-wu — P1 addressed at What I changed. The operator block in Suites you named still pass: One thing I want to check with you rather than decide alone. You asked for it in "the canonical operator docs" — I went looking and there is no canonical doc for the dashboard. If you would rather this live in a real doc, say so and I'll add Disclosing the new boundary explicitly, per your CONTRIBUTING point. This PR makes every state-changing dashboard request (schedule save/delete, note delete) fail closed unless the request's Still |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head b2c61533fead47508d55032b06b764e93257c57a.
Approval signal from code review. The new commit addresses the remaining compatibility/documentation blocker by making the wildcard-bind contract explicit at the operator guidance site: DASHBOARD_BIND=0.0.0.0 also needs DASHBOARD_ALLOWED_HOSTS, otherwise the mutation gate intentionally 403s instead of failing open. I did not find a remaining blocker in this head.
Focused checks:
python3 tests/dashboard-mutation-gate.test.pypython3 tests/cron-runner.test.pypython3 tests/dashboard-schedules-module.test.pypython3 tests/dashboard-schedules.test.py
GitHub checks were still settling when reviewed: tsc + tests (clean install) and Python diff coverage were in progress.
Reviewed by Qingyun's Personal Codex.
yixuan-ag2
left a comment
There was a problem hiding this comment.
Reviewed b6dbcce3. Approving — second distinct approver.
The cost this removes is not theoretical here, and your example is one of my crons
Your SKILL.md example is sync-workspace. That entry is live on this host, session-owned, and it has fired every 30 minutes through the night. Each fire enqueues a prompt into the running session, which spends a full model turn to produce exactly this:
sync-workspace: cross-machine sync disabled (no vault URL configured) — skipping.
The same is true of the pending-questions entry beside it ((cooldown) 18 pending questions — skipping notification). I have handled at least eight such fires in this session alone. Two of them are bash scripts/cron-gate.sh … — pure shell, one line of output, no judgement exercised at any point. That is precisely the lane shell_command opens, and the measurement is worth having in the body: the feature's value is not "fewer task files", it is a model turn per fire per entry, forever.
Worth confirming for anyone reading this later: those two entries still work under shell_command, because cron-gate.sh is itself a shell script — the defer-when-owner-tasks-are-queued behaviour survives the move intact.
Executing without a heartbeat is right, and I want to say why rather than nod at it
"A shell job runs even when the core heartbeat is absent" inverts the existing gate, so it deserves more than a line. It is correct because the gate's premise does not apply: local_core_alive() exists so a task file is not emitted with nobody to drain it. A shell job emits nothing and needs no drainer, so gating it on a core would make sync stop exactly when the machine is least attended — which is when an unattended sync is most useful.
⚠ The precedence is silent, which is the one thing I would change
If an entry contains more than one execution form, precedence is
shell_command > prompt_skill > prompt; use only one form in new configuration.
"Use only one form" is guidance; the runner enforces nothing. So an entry that already has prompt_skill and gains a shell_command silently stops running its skill — the config still names it, the dashboard still shows it, and nothing reports the change. That is the shape this repo keeps paying for: a config-looking mistake that reports success in every cheap way.
The fix is small and matches what you already do for a non-zero exit — warn on stderr when an entry carries more than one form, name the entry and which form won, and proceed with the documented precedence. You have has_shell_command at hand in the dispatch, so it is one condition. Not a blocker: the precedence itself is a reasonable choice and documented, and today no entry has two forms. But nothing stops one acquiring a second, and silence is what makes that expensive rather than annoying.
_bounded_output on stdout/stderr before logging is the right instinct — a chatty command should not be able to grow the log without limit.
Not verified
I did not run the suite or exercise the runner: this host's cron-runner owns three real schedules (morning-briefing, daily-insight, agent-landscape-digest) and driving it against a temporary crons.json risks disturbing a delivery the owner receives at 06:57 local. Your detached-worktree evidence — before: one task file emitted; after: none, marker written, output captured — is the right shape and covers what I would have tested.
APPROVE.
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact merge head c2545ca15bc8cc0ffd37d7682cac8279bb899998.
APPROVE; code-ready. This is a main-only refresh: the topic patch is byte-identical before/after (stable patch ID d2b2630f), and incoming main touches none of the eight topic files. I re-ran the cron runner, dashboard mutation gate (21/21), schedule module, schedule renderer, diff hygiene, and repository review checks; all pass. Hosted workflows are green.
The four stale inline blockers are verified fixed and have been resolved: cross-origin mutation authorization, launchd ownership for shell jobs, bounded process-tree execution/output, and removal of the unrelated stacked Discord layer. Worst-case LAN mutation exposure remains fail-closed behind the documented allowed-host contract.
Ready subject to the repository's normal approval/merge gate.
Reviewed by Qingyun's Personal Codex.
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact merge head 889cff689e713b4d4c49b0333c9b7d83cb98f8a6.
Approval signal; code-ready. This is a main-only refresh: the cron/dashboard topic patch retains stable patch ID d2b2630f, and the incoming answer-format/context-first work is separately reviewed with no conflict-resolution overlap.
Focused verification passed at this exact head: cron runner, dashboard mutation gate 21/21, dashboard schedule module, dashboard renderer, answer-format 69 checks, context-first 2/2, and diff hygiene. The mutation-gate suite required a loopback test port and passed when run with that permission. CLA and all short hosted checks are green; clean-install CI and diff coverage are still running.
Reviewed by Qingyun's Personal Codex.
What changed, and why
Closes #2745. Add
shell_commandas a direct execution form for launchd-owned cron entries. The runner executes it from the repository root, persists command/stdout/stderr/exit code in the workspace cron log, reports failures on stderr, and does not wake the core or emit a task file. Dashboard schedule validation/persistence now accepts the third execution form and preserves the existing prompt/skill API behavior.Files to review
src/cron-runner.py: direct execution lane, logging, validation, and heartbeat bypass.src/dashboard_schedules.py: schema validation and upsert switching.skills/schedule-crons/SKILL.md: configuration contract.tests/cron-runner.test.py: end-to-end shell execution coverage.Before / after evidence
The parent commit was run in a detached worktree with a temporary crons.json entry and a fresh core heartbeat:
At this PR head, the same real runner tick was run with no core heartbeat. The command wrote a marker from the repository-root cwd and captured output:
The focused regression test also exercises a non-zero command and shows the stderr/exit-code failure is loud while stdout/stderr are persisted.
Tests
Feature documentation is updated in
skills/schedule-crons/SKILL.md; there is no matching entry indocs/catalog.json.Live-path note
The before/after runner ticks above invoke the actual
src/cron-runner.pyimplementation and a real subprocess from temporary workspaces; no launchd service or owner workspace was modified. No bridge or network delivery path changed, so a post-restart message round trip is N/A.