Skip to content

feat(hub): native-resume recipes as shared data, and one resume dispatch (N1) - #533

Merged
physercoe merged 3 commits into
mainfrom
feat-n1-resume-recipes
Aug 8, 2026
Merged

feat(hub): native-resume recipes as shared data, and one resume dispatch (N1)#533
physercoe merged 3 commits into
mainfrom
feat-n1-resume-recipes

Conversation

@physercoe

Copy link
Copy Markdown
Owner

Pane-state-manifests plan N1 — the wedge #532's review pass said to take first, because its consumer (vision-parity L3) is already scheduled for W3.

What lands

hub/internal/resumerecipes — 17 engine CLIs and how each reattaches to a prior session, plus a mapping from our agent families onto a resume mechanism (argv / acp_session_load / appserver_thread_resume). Sixteen rows are transcribed from herdr src/agent_resume.rs @ 6f311498 (v0.8.0, Apache-2.0 — NOTICE updated); the gemini row is ours, grounded in driver_exec_resume.go.

It ships as YAML, not Go, because the declared consumer is the Companion's local agent service in Electron main. It cannot call a Go table, only re-derive one — so a Go-only N1 would have guaranteed the second copy it exists to prevent. testdata/resume_recipes_fixture.json is generated from the table and compared byte-for-byte every run, so the file going stale is the signal that the two languages may disagree. Same recipe L2 shipped in #526.

Accuracy is recorded per row

Grade Rows Basis
probe claude, codex, agy ran each binary's --help on this host
in-tree gemini our own driver + corpus fixture
vendored the other 13 herdr only — we have not run these

A test fails if a row is promoted to probe without someone running it. Vendored reference data gets less review than vendored code and fails silently downstream, so the grade lives in the data instead of being averaged across the file.

Two mappings are refused on purpose:

  • kimi-code-ts is not wired to herdr's kimi --session. Nobody has confirmed it is the same binary as our compiled-TS CLI (ADR-054), and a wrong recipe doesn't error — it cold-starts.
  • gemini-cli is acp_session_load, not argv. The hub injects the ACP field; the --resume the M2 driver uses is threaded per turn from a cursor it captured itself. Calling it an argv family would have made the rewired splice rewrite a cmd the hub has never rewritten.

What the rewire turned up

The plan named the wrong site. It said driver_exec_resume.go / teleport respawn. That file is correctly driver-internal and is untouched; the real knowledge was in resume_splice.go plus two hand-copied family switches — and they had already diverged. respawn_with_spec_mutation.go's copy never grew antigravity's arm.

That divergence is latent, not live: step 2 of that path returns errUnknownFamilyField for any family absent from flagForField, and antigravity is absent, so an antigravity agent never reaches the splice. But adding antigravity to flagForField would have silently turned every mode/model flip into a cold start. Both call sites now share one spliceResume.

Also latent and now covered: kimi-code, the retired Python family (#378), still has a live arm for rows persisted before its retirement. Anyone enumerating spawnable engines would have dropped it and silently cold-started those sessions. It has a row, and a test pins every family the hub actually splices for.

Shell injection (please look here)

engine_session_id is captured verbatim from the agent's own session.init payloadcaptureEngineSessionID validates nothing — and spliced into backend.cmd, which tmux runs through a shell. It was joined in unquoted (strings.Join(out, " ")), so an id like x; rm -rf / would have executed with the agent's privileges at the next resume, respawn or teleport.

Values now pass a validation envelope (non-empty, no control chars, ≤512 bytes) and are quoted only when they need it, so every id that was already safe renders byte-identically and no live spawn spec changed. An id quoting cannot rescue is refused and the respawn cold-starts — the fallback that path already takes everywhere else.

The quoting is checked against a real sh, not my model of one: the test swaps the binary for printf and asserts the shell hands the id back byte-identical.

Mutation checks

  • ShellQuote returns input unchanged → hostile test fails.
  • Swap single→double quotes → passes the structural check, fails the shell check on $(…) and backticks. Both seams are live and independent.
  • Dispatch ignores the family → argv families fail.
  • Delete the kimi-code row → fails in both the table test and the dispatch test.

Not done, deliberately

The dedupe rule is a primitive, not a policy. DedupeKey exists and is tested; nothing consumes it, because there is no single restore-pass owner in our architecture to hang "one ref resumes once" on. Wiring it needs that owner identified first — a separate wedge, and I've recorded it as such in the plan rather than half-building it.

Verification

  • Full go test ./...exit 0, 32 packages.
  • 17 pre-existing splice tests pass untouched (the wrappers keep their names precisely so they still pin behaviour).
  • 11 runnable CI lints pass; lint-openapi needs a venv this machine lacks.
  • NUL 0 / CJK 0 on all 13 changed files; gofmt clean on everything I touched.

Docs: new reference/engine-resume-recipes.md, plan updated with the four as-built deviations, changelog gains Added / Security / Fixed entries.

🤖 Generated with Claude Code

…tch (N1)

Pane-state-manifests plan, N1 — the wedge the review pass in #532 said to
take first because its consumer (vision-parity L3) is already scheduled.

## The table

New `hub/internal/resumerecipes`: 17 engine CLIs and how each reattaches
to a prior session, plus a mapping from our agent families onto a resume
MECHANISM (argv / acp_session_load / appserver_thread_resume). Sixteen
rows transcribed from herdr `src/agent_resume.rs` @ 6f311498 (v0.8.0,
Apache-2.0 — NOTICE updated); the `gemini` row is ours, grounded in
driver_exec_resume.go.

It ships as YAML, not Go literals, because the declared consumer is the
desktop Companion's local agent service in Electron main. It cannot call
a Go table, only re-derive one, so a Go-only N1 would have guaranteed the
second copy it exists to prevent. `testdata/resume_recipes_fixture.json`
is generated from the table and compared byte-for-byte on every run, so
the file going stale IS the signal that the two languages may disagree —
the recipe L2 shipped in #526.

## Accuracy, recorded per row

claude / codex / agy were verified by running each binary's `--help` on
this host; `gemini` is verified by our own driver; the other 13 are
marked `vendored` and a test fails if one is promoted to `probe` without
someone running it. Vendored reference DATA gets less review than
vendored code and fails silently downstream, so the grade lives in the
data rather than being averaged across the file.

Two mappings are refused on purpose. `kimi-code-ts` is NOT wired to
herdr's `kimi --session` — nobody has confirmed it is the same binary as
our compiled-TS CLI, and a wrong recipe does not error, it cold-starts.
`gemini-cli` is `acp_session_load`, not `argv`: the hub injects the ACP
field, and the `--resume` the M2 driver uses is threaded per turn from a
cursor it captured itself. Calling that an argv family would have made
the rewired splice rewrite a cmd the hub has never rewritten.

## What the rewire found

The plan said the ad-hoc knowledge lived in driver_exec_resume.go /
teleport respawn. It didn't. driver_exec_resume.go is correctly
driver-internal and is untouched; the real site was resume_splice.go plus
TWO hand-copied family switches — and they had already diverged. The
spec-mutation copy never grew antigravity's arm. That is latent, not
live: step 2 of that path returns errUnknownFamilyField for any family
absent from flagForField, and antigravity is absent. But adding
antigravity to flagForField would have silently turned every mode/model
flip into a cold start. Both call sites now share one `spliceResume`.

Also latent and now fixed: `kimi-code` (the retired Python family, #378)
still has a live arm for rows persisted before its retirement. An
enumerator listing spawnable engines would have dropped it and silently
cold-started those sessions; it has a row, and a test pins every family
the hub splices for.

## Shell injection

engine_session_id is captured verbatim from the agent's own session.init
payload (captureEngineSessionID validates nothing) and spliced into
backend.cmd, which tmux runs through a shell. It was joined unquoted, so
`x; rm -rf /` would have executed at the next resume, respawn or
teleport. Values now pass a validation envelope and are quoted only when
they need it, so every id that was already safe renders byte-identically
and no live spawn spec changed. An id quoting cannot rescue is refused
and the respawn cold-starts — the fallback that path already takes
everywhere else.

The quoting is checked against a real `sh`, not against my model of one:
the test replaces the binary with `printf` and asserts the shell hands
the id back byte-identical. Mutation-checked — swapping to double quotes
passes the structural check and FAILS the shell check on `$(…)` and
backticks, so both seams are live and independent.

Not done: the dedupe rule is a primitive (`DedupeKey`, tested), not a
policy. Nothing consumes it, because there is no single restore-pass
owner to hang "one ref resumes once" on. That needs identifying first.

Verification: full `go test ./...` exit 0; 11 runnable CI lints pass
(lint-openapi needs a venv this machine lacks); 17 pre-existing splice
tests pass untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread hub/internal/server/resume_splice.go Fixed
Ubuntu and others added 2 commits August 8, 2026 05:27
CodeQL flagged `make([]string, 0, len(tokens)+2)` in rewriteResumeFlag as
an overflowable size computation. The pattern predates this PR (the old
antigravity rewriter had it too), but it is newly reachable from
attacker-influenced input now that the splice path validates and quotes a
session id the engine supplies, so the alert is fair.

The capacity is a hint — append grows past it — so the arithmetic bought
nothing. len(tokens) alone, with the reason recorded at the line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewriteResumeFlag scans cmd tokens for the engine binary and accepts a
path suffix match, and specs commonly lead with `cd <workdir> &&`. A
workdir that happens to end in the engine's name — `cd ~/w/claude &&
claude --model x` — matched on cd's operand first, splicing the resume
flag onto the directory: `cd ~/w/claude --resume <id> && …`, which does
not cold-start, it kills the spawn outright (`cd: too many arguments`).

A token that is `cd`'s operand is a directory, never an invocation, so
the scan now skips it. Pre-existing in agy's old scanner; the table
rewrite generalized it to claude and every future argv engine, which is
why the fix and its pin live in the shared rewriter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants