fix(daemon): reject unsupported raw --save-script flags at request ingress - #1501
Conversation
…gress `flags.saveScript` arms session-script publication the moment a successful handler records the request's action, so any recordable command reaching a handler with the raw flag set could arm publication and write a `.ad` artifact — `record stop` immediately through record-only cleanup, `trace` and the interaction commands later on close. The CLI/Node/MCP surfaces only emit the flag for its released owners, so that arming path was reachable only by a hand-built wire request or a free-form `batch` step. Close it at the daemon request seam (`createRequestHandler`), the single entry point both the socket and HTTP transports funnel through, and ahead of admission, session lookup, device resolution, and handler dispatch. Ownership is declared on the command descriptor (`saveScriptFlagOwner`) rather than re-stated as a string set, so `open`, `close`, and `replay` keep the flag and everything else is rejected with an ADR 0010 shaped `INVALID_ARGS` carrying a hint, diagnosticId, and logPath. With that path pinned closed, a record-only session can no longer carry `recordSession`, so `record stop`'s immediate `writeSessionLog` could only ever be a no-op; delete it. Refs #1478. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXQLYV7etZx3gcXsUsrQJ8
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
thymikee
left a comment
There was a problem hiding this comment.
Verdict: approve after one small test amendment. I found no production correctness issue.
The ingress placement is correct, both transports converge there, batch nesting is covered, and deleting the record-only writer is justified once the raw path is closed. I also checked the replay-scoped fast path: replay actions neither inherit saveScript nor parse it from .ad actions, so it does not bypass this policy.
One requested amendment: the HTTP test helper currently projects only code, message, and hint, discarding diagnosticId and logPath. That means the ADR 0010 guarantee could regress while these new transport tests remain green. Please preserve those fields in the helper and assert for both transports that diagnosticId is present and logPath is present and exists. Ideally, also verify that the log contains save_script_flag_rejected.
I tested that amended assertion locally; it passes on both socket and HTTP. The focused exact-head suite passed 18/18, and all completed GitHub checks were green at review time, with one iOS smoke lane still running.
The HTTP test helper projected only code/message/hint, so `diagnosticId` and `logPath` could regress while the transport tests stayed green. Preserve both in the helper and assert on each transport that `diagnosticId` is present, `logPath` is present and exists, and the log records `save_script_flag_rejected`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXQLYV7etZx3gcXsUsrQJ8
|
Amendment pushed in The HTTP helper built its error object from only I also ran the negative control the request implies: restoring the old projection makes the HTTP case fail on Focused suites 18/18, and Thanks for the replay fast-path check — that replay actions neither inherit Generated by Claude Code |
|
Clean review for P4-pre at Residual risk: no separately authorized cross-vendor review was performed. |
|
…tep port Review finding on #1509: the native `.ad` reporter ratchet exercises only one of the two `onStep` forwarding chains, so deleting a link in the Maestro chain would silently stop `onTestStep` for every `test --maestro` run while every existing reporter test stayed green. Same defect class as the dropped diagnosticId/logPath (#1501) and the dropped reporter `hint` (#1505). Maestro is one of P3's two required real adapters and its chain shares no links with the native one below `runReplayScriptFile`: scheduler sink -> runReplayScriptFile -> runTypedMaestroReplayFile -> createMaestroReplayObserver({ onStep }) -> actionStarted -> onStep Adds a Maestro scenario driving `test --maestro` through the real session handler and the real reporter registry. It asserts the step payload the engine produces (`stepIndex`/`stepTotal`/`stepCommand`/`stepValue`, including that a value-less command stays value-less) together with the attempt/session identity the scheduler supplies, since that half of the event came from request-global AsyncLocalStorage before P3. A second case drives a retry so step events must carry attempt-1's session and then attempt-2's. The flow `name` also pins the reporter `title`, a value only the Maestro path can produce. New file rather than an addition to session-test-reporter-values.test.ts: that file is the pinned characterization and must keep passing unmodified, and Maestro needs its own vi.mock of core/dispatch for device resolution. Counterfactual run, both links, each restored after: - dropping `onStep` from createMaestroReplayObserver in session-replay-maestro-runtime.ts - dropping the emitMaestroStep call from actionStarted in session-replay-maestro-observer.ts Each dropped both onTestStep events ("expected [ 'onSuiteStart', 'onTestStart', …(2) ] to deeply equal [ 'onSuiteStart', 'onTestStart', …(4) ]") and failed both new cases, while session-test-reporter-values.test.ts passed all 4 — exactly the hole the reviewer identified. Test-only; no production change. Bundle output is byte-identical to b339c64. Refs #1478 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXQLYV7etZx3gcXsUsrQJ8
…eam (#1478 P3, part 1) (#1509) * refactor(replay-test): neutralize the values crossing the scheduler seam #1478 P3, part 1 of 2. Prepares the replay-test extraction by removing every non-neutral value that crosses the scheduler seam, in place under `src/`, so the physical move to `packages/replay-test` is a file move rather than a redesign. `DaemonResponse` no longer crosses the seam. `session-test-types.ts` typed `runReplay`/`finalizeAttempt` as returning a daemon response and the scheduler read `.error.code`, `.error.details`, and `.data.replayed/.healed/.warnings/ .snapshotDiagnostics` off it throughout. That is invisible to R10 today only because `checkDaemonTypesImporters` skips `src/daemon/`; once the files live in a package they become external `daemon/types.ts` importers, which the ratchet only lets shrink. Attempts now resolve as tagged `ReplayTestAttemptOutcome` values carrying exactly what the scheduler consumes, including an `infrastructure` tag — classifying an environmental failure needs platform boot-diagnostic vocabulary the scheduler must not import, so the host decides and the scheduler reads the verdict. `session-test-outcome.ts` is the one place a daemon response becomes an outcome. Step events get a narrow per-attempt port. They were emitted from `session-replay-runtime.ts` and `session-replay-maestro-observer.ts`, both reading a request-global `AsyncLocalStorage` seeded per attempt. The scheduler now hands each attempt an `onStep` sink, threaded the way `tracePath` already is; both engines call it and `withReplayTestActionProgress`/`readReplayTestActionProgress` are gone. A direct `replay` simply has no sink. ADR 0012 divergence becomes a neutral leaf. `src/replay/divergence.ts` depended only on kernel contracts and redaction, yet Maestro constructs divergences too and CLI/MCP both render them, so P5 could not have moved it into `packages/ad-replay`. It is now `@agent-device/contracts/divergence`; the renderer's output text is unchanged. The progress wire vocabulary moves to `@agent-device/contracts/progress`. It is serialized by `request-progress-protocol.ts` and reconstructed by the CLI reporter path, so it belongs below both; `src/request/progress.ts` keeps only the sink and its AsyncLocalStorage binding. Together these clear all four of replay-test's recorded R10 migration imports, so the rule now enforces unconditionally for that module. Behavior is unchanged. The shipped reporter contract — export spellings, object/factory loading, hook names, timing/order, value fields, the synchronous live-hook rule, awaited suite completion, error handling, exit codes — is untouched, and `session-test-reporter-values.test.ts` passes unmodified. The `--shard-all` `total`/`runnable` asymmetry is preserved as characterized. Refs #1478 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXQLYV7etZx3gcXsUsrQJ8 * test(replay-test): pin the Maestro reporter step path against the onStep port Review finding on #1509: the native `.ad` reporter ratchet exercises only one of the two `onStep` forwarding chains, so deleting a link in the Maestro chain would silently stop `onTestStep` for every `test --maestro` run while every existing reporter test stayed green. Same defect class as the dropped diagnosticId/logPath (#1501) and the dropped reporter `hint` (#1505). Maestro is one of P3's two required real adapters and its chain shares no links with the native one below `runReplayScriptFile`: scheduler sink -> runReplayScriptFile -> runTypedMaestroReplayFile -> createMaestroReplayObserver({ onStep }) -> actionStarted -> onStep Adds a Maestro scenario driving `test --maestro` through the real session handler and the real reporter registry. It asserts the step payload the engine produces (`stepIndex`/`stepTotal`/`stepCommand`/`stepValue`, including that a value-less command stays value-less) together with the attempt/session identity the scheduler supplies, since that half of the event came from request-global AsyncLocalStorage before P3. A second case drives a retry so step events must carry attempt-1's session and then attempt-2's. The flow `name` also pins the reporter `title`, a value only the Maestro path can produce. New file rather than an addition to session-test-reporter-values.test.ts: that file is the pinned characterization and must keep passing unmodified, and Maestro needs its own vi.mock of core/dispatch for device resolution. Counterfactual run, both links, each restored after: - dropping `onStep` from createMaestroReplayObserver in session-replay-maestro-runtime.ts - dropping the emitMaestroStep call from actionStarted in session-replay-maestro-observer.ts Each dropped both onTestStep events ("expected [ 'onSuiteStart', 'onTestStart', …(2) ] to deeply equal [ 'onSuiteStart', 'onTestStart', …(4) ]") and failed both new cases, while session-test-reporter-values.test.ts passed all 4 — exactly the hole the reviewer identified. Test-only; no production change. Bundle output is byte-identical to b339c64. Refs #1478 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXQLYV7etZx3gcXsUsrQJ8 --------- Co-authored-by: Claude <noreply@anthropic.com>
Implements P4-pre from #1478. Scoped strictly to that brief: no tagged aggregate, no supported-writer migration.
Why
flags.saveScriptarms session-script publication as soon as a successful handler records the request's action (recordActionEntrysetsrecordSessionand the target path). Any recordable command that reaches a handler with the raw flag set can therefore arm publication and later write a.adartifact —record stopwrites one immediately through record-only cleanup, whiletraceand the interaction commands publish later on close.The CLI, Node client, and MCP surfaces only ever emit the flag for its released owners, so this arming path was reachable only by a hand-built wire request.
The seam
Rejection lives in
createRequestHandler'srunRequestWithinScope(src/daemon/request-router.ts), directly after the existingrecordingFlagsResponsesibling and beforecreateRequestExecutionScope— so it precedes lease admission, session lookup, device binding, provider scope, and handler dispatch.daemon-runtime.tsbuilds exactly onehandleRequestand passes the same function to bothcreateSocketServer(...)andcreateDaemonHttpServer({ handleRequest }), so the socket wire, HTTP/rpc, and nestedbatchsteps all meet it. ValidatingcommandRpcParamsSchemaalone would have left the socket transport open.Rule:
flags.saveScript !== undefinedon a command whose descriptor is not asaveScriptFlagOwner→INVALID_ARGS, normalized throughnormalizeErrorwith an explicithint, plusdiagnosticIdandlogPathfrom a forced diagnostics flush (ADR 0010 decision 6), and asave_script_flag_rejecteddiagnostic.Presence is rejected, not truthiness — a raw
saveScript: falseis as unsupported onrecordastrueis, mirroring the maestro replay guard's!== undefinedreading of the same flag. Ownership is derived from the command descriptor registry rather than a hand-written string set, and resolves to exactlyopen,close,replay.Behavior deleted
releaseRecordOnlySession'swriteLogoption and the{ writeLog: true }call atrecord stop— i.e.sessionStore.writeSessionLog(session)for record-only sessions. Deleted only after the seam tests were green.Files
Created
src/daemon/request-save-script-policy.ts—unsupportedSaveScriptFlagResponse(req)+SAVE_SCRIPT_FLAG_OWNER_COMMANDSsrc/daemon/__tests__/request-save-script-policy.test.tssrc/daemon/__tests__/request-save-script-transports.test.tsModified
src/daemon/request-router.ts— policy call inrunRequestWithinScopesrc/daemon/daemon-command-registry.ts—saveScriptFlagOwnertrait +ownsSaveScriptFlag/listSaveScriptFlagOwnerCommandssrc/core/command-descriptor/registry.ts—saveScriptFlagOwner: trueonopen,close,replayonlysrc/daemon/handlers/record-trace-recording.ts— dormant writer removalCHANGELOG.mdTests
request-save-script-transports.test.tsdrives the realcreateRequestHandlerbehind real socket and HTTP servers, parameterized over both transports:recordSession/saveScriptPathuntouched,writeSessionLogreturns{written:false}, no.adanywhere under the temp root;sessionIsolation: 'tenant'without a tenant, which admission rejects with its own message — the save-script message wins, so the seam is genuinely ahead of admission;replay --save-scriptreaches its handler;batchstep carrying the flag ontraceis rejected;.ad.Negative control: with the seam short-circuited, 4 of these fail (both transports' arm/write plus both pre-admission cases), and
trace --save-scriptarmed and started as before.Gates
pnpm check:affected --base origin/main --run→ pass (format, lint, typecheck, layering, fallow; 298 test files / 2898 tests)vitest --project unit-core src/daemon src/core/command-descriptor→ 187 files / 1589 testsactive-session-script-publication,no-record-recorder-routes,replay-repair-abort-close,replay-repair-record-exclusion,daemon-transport,daemon-http-server) → 19 tests, confirmingopen/close/replayarm and publish unchangedpnpm typecheck,pnpm lint,pnpm check:layering,pnpm check:fallow,pnpm check:production-exports,pnpm format:check,pnpm check:mcp-metadatarpcProtocolVersionnot bumped.One judgement call for review
DaemonBatchStep.flagsis free-formRecord<string, unknown>passthrough, so a Node/MCPbatchpayload could technically have setsaveScripton a non-owner step today and armed publication. This PR treats that as the same undeclared raw arming path the brief asks to close — no command schema, CLI grammar, or MCP tool ever advertised it — and pins it with a test.If you consider free-form batch step flags a released surface, this is the one decision to revisit.
Checked separately:
session save-scriptis positional-based (['save-script', './screen-x.ad']), notflags.saveScript, so it is unaffected.Left for P4a
No tagged aggregate;
ReplaySessionTransaction/SessionScriptPublicationuntouched.SessionStore.recordAction/recordActionEntrystill arm from request flags for the owner commands — that is the P4a migration target.Parallelism note
Developed concurrently with P2. No P2-owned file is touched:
packages/maestro,src/daemon/adapters/maestro/,src/daemon/handlers/session-replay-runtime.ts, andsrc/daemon/handlers/session-test-discovery.tsare all untouched, so this should merge without interacting with the Maestro extraction.Generated by Claude Code