[sprites 2-3] Real session termination via the kill endpoint#2050
Conversation
|
Warning Review limit reached
Next review available in: 58 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (18)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c6397823fb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| sessionId: string, | ||
| ): Promise<void> { | ||
| const response = await fetchImpl( | ||
| `${baseURL}/v1/sprites/${encodeURIComponent(spriteName)}/exec/sessions/${encodeURIComponent(sessionId)}/kill`, |
There was a problem hiding this comment.
Use the documented session kill URL
The Sprites exec API documents the kill endpoint as POST /sprites/{name}/exec/{session_id}/kill (and the SDK example builds ${client.baseURL}/v1/sprites/${spriteName}/exec/${targetSession.id}/kill), but this helper posts to /exec/sessions/{id}/kill. For every explicit terminal kill or idle reap this wrong route can return 404, which this same helper treats as idempotent success, so callers will remove the terminal row/close the pane while the server-side session keeps running and billing.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in f5fcd19 — thank you, this was a real bug.
I verified against the live docs (https://sprites.dev/api/sprites/exec#kill-exec-session): the documented endpoint is POST /sprites/{name}/exec/{session_id}/kill, not .../exec/sessions/{id}/kill. The extra sessions/ segment was wrong.
Went further than just the URL, since the same root issue (trusting a response too loosely) extends to the success path too: the docs show 200 returns streaming NDJSON progress (signal → exited/killed → complete, or a mid-stream {"type":"error",...} line if the signal couldn't actually be delivered). The old code only checked response.ok, so it would have silently treated a 200 with a mid-stream error as success too. killSpriteSession now:
- Uses the correct URL (
/exec/{session_id}/kill). - Drains the response body on
200and rejects if any NDJSON line reportstype: 'error'(new pure helperkillSessionStreamErrorMessage, unit-tested). - Still treats
404/410as idempotent success — confirmed via the docs' own "Attach to Exec Session" / "List Exec Sessions" endpoints, which document404as the response for a session that doesn't exist (the same resource family, so I'm treating that as representative forkilltoo, since the kill endpoint's own docs list200/404/500as its only documented codes). - Added a 10s wall-clock timeout via
AbortControlleraround the whole request+drain, matching this driver's existing pattern for every other network call (filesystem ops, checkpoints) — an unbounded rawfetchwould've been the one exception.
Full verification re-run after the fix: packages/lib 8025 tests, apps/realtime 825 tests, apps/web machine routes 167 tests, full monorepo typecheck (16/16 packages) — all green.
Leaving this thread open for your verification rather than auto-resolving it, since it represents real work done during this convergence pass, not something that was already fixed before I started.
There was a problem hiding this comment.
Follow-up: ran a broader multi-angle self-review after this fix and found a few more things worth closing in the same pass (189954a):
- No retry on transient failure.
killSpriteSessionnow retries up to 3 times with linear backoff on ANY failure (not just a structurally-detected pre-open drop like the exec/spawn paths in this file require) — a kill is idempotent by construction, so blanket retry is safe, and it closes a real gap: both the fire-and-forget kill fromPtyShell.kill()and the DB-row-keeping kill fromkillAtLocationpreviously gave up after one attempt, even against the exact cold-Sprite wake-on-request drop this codebase already has dedicated retry machinery for elsewhere. - Duplicated SDK wrapper. The
withKillSessionbolt-on was copy-pasted near-verbatim acrossapps/webandapps/realtime's SDK factories. Hoisted into one shared, exportedwithKillSessioninsprites.ts. - Misleading trigger name.
teardownAgentTerminalSession's only real trigger was'user-kill', but that path is the platform force-ending a session (revoked access / insolvent payer) — never an actual user click. Renamed to'forced-teardown'so an incident review doesn't misattribute cause. - Fixed a now-stale comment in
sprite-machine-host.ts'sstream()doc that still claimed to protectkillAgentTerminal's kill path — that caller no longer usesstream().
Re-verified: packages/lib 8029 tests, apps/realtime 825 tests, full monorepo typecheck (16/16) — all green.
The kill endpoint was called at /v1/sprites/{name}/exec/sessions/{id}/kill,
but the documented (and real) path is /v1/sprites/{name}/exec/{session_id}/kill
(sprites.dev/api/sprites/exec#kill-exec-session) — no "sessions/" segment.
Every real kill 404'd against the wrong route, and killSpriteSession's own
404-is-idempotent-success handling silently swallowed it: rows/panes were
torn down client-side while the server-side session kept running and billing.
Also: a 200 response is streaming NDJSON progress (signal -> exited/killed ->
complete, or a mid-stream `error` line if the signal couldn't be delivered) —
the HTTP status alone doesn't confirm the kill succeeded. killSpriteSession
now drains the body and rejects on any `type: 'error'` line via the new pure
killSessionStreamErrorMessage helper, and the whole call (request + drain) is
now bounded by a 10s timeout via AbortController, consistent with every other
network call this driver makes (filesystem ops, checkpoints).
Fixes a P1 correctness finding from automated review on PR #2050.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013mYrPXsCJFegYupazDEDGe
… rename trigger Proactive convergence pass addressing findings from a multi-angle automated review of PR #2050: - killSpriteSession now retries (MAX_EXEC_ATTEMPTS, linear backoff, same schedule as withWakeRetry) on ANY failure — not just structurally-detected pre-open drops like the exec/spawn paths in this file. A kill is idempotent by construction (404/410 already means success), so blanket retry is safe and closes a real gap: the fire-and-forget kill from PtyShell.kill() and the DB-row-keeping kill from killAtLocation both previously gave up after one attempt, even against a transient failure exactly like the cold-Sprite wake-on-request drop the exec paths already guard against. - Hoisted the `withKillSession` SDK-wrapping bolt-on (three independent reviewers flagged the same near-verbatim duplication) into a single exported `withKillSession` in sprites.ts, used by both apps/web/src/lib/ sandbox/sprites-client.ts and apps/realtime/src/terminal/ realtime-sprites-client.ts instead of each defining its own copy. - Renamed the `TeardownTrigger` value `'user-kill'` to `'forced-teardown'`. Its only real callers (teardownAgentTerminalSession, on revoked access or an insolvent payer) are the platform ending a session, never a literal user click — the actual user-facing kill path (killAgentTerminal) doesn't go through PtyShell/TeardownTrigger at all. The old name would read, in an incident review, as "the user did this." - Documented why `detach`/`shell-exit` have no production call site today (both are part of planTeardown's tested decision matrix, not dead code) and fixed a stale comment in sprite-machine-host.ts's stream() doc that still claimed to protect killAgentTerminal, which no longer calls stream(). Verification: packages/lib 8029 tests, apps/realtime 825 tests, full monorepo typecheck (16/16) — all green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013mYrPXsCJFegYupazDEDGe
|
@coderabbitai review |
|
✅ Action performedReview finished.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 189954a326
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| `${baseURL}/v1/sprites/${encodeURIComponent(spriteName)}/exec/${encodeURIComponent(sessionId)}/kill`, | ||
| { method: 'POST', headers: { Authorization: `Bearer ${token}` }, signal: controller.signal }, |
There was a problem hiding this comment.
Preserve SIGKILL semantics for session termination
In detached/idle-reap and killAgentTerminal paths this REST call is the only signal that can reach the server-side PTY, but the Sprites kill endpoint defaults to SIGTERM when no signal query is provided, whereas the old stream-based path used SIGKILL. For agents or child processes that trap/ignore TERM, the row/session bookkeeping can be removed after this call even though the billable exec session is still alive; please request signal=SIGKILL (or otherwise escalate/fail on non-termination) to keep explicit kills and reaps authoritative.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 979cc4c — thank you, this was a real gap.
I verified the endpoint contract directly (sprites.dev/api/sprites/exec#kill-exec-session): signal is an optional query param, default: SIGTERM. The old WS-based kill("SIGKILL") path this REST call replaces always sent SIGKILL, so a process that traps/ignores TERM would have survived a default-signal call while killSpriteSession still reported success — exactly the bookkeeping-vs-reality gap you flagged.
Fix: attemptKillSpriteSession now requests ?signal=SIGKILL explicitly on every kill (both the killAgentTerminal REST path and the idle-reap path go through this same function, so both are covered). Updated the doc comment to spell out why the explicit signal matters, and added a dedicated test asserting the signal=SIGKILL query param is present on every kill request, plus updated the two existing URL-shape assertions to include it.
Leaving this thread open per our convergence process (only auto-resolving threads that were already fixed before this loop started) — happy for you to verify on the new commit.
Every teardown path called kill('SIGKILL') on the client WebSocket handle, but
TTY sessions have max_run_after_disconnect: 0 and keep running server-side
after the socket closes. Nothing called the documented kill endpoint
(POST /v1/sprites/{name}/exec/sessions/{id}/kill), so killed/reaped panes
could leak live sessions.
- sprites.ts: SpriteInstanceLike gains killSession(sessionId); killSpriteSession
drives the REST endpoint directly (the SDK exposes no wrapper), idempotent
on 404/410.
- sprites-shell.ts: PtyShell.kill() now takes a TeardownTrigger
('user-kill' | 'detach' | 'idle-reap' | 'shell-exit'); planTeardown decides
{killSession, closeSocket} purely. user-kill/idle-reap kill the session by
id (reaches a session even with no live socket); detach/shell-exit never
terminate the remote process.
- agent-terminal-handler.ts: wires 'user-kill' (forced teardown on revoked
access/insolvent payer) and 'idle-reap' (30-min detached reap) triggers.
- machine-host.ts / sprite-machine-host.ts: MachineHandle gains killSession,
backed by the same REST call.
- agent-terminals.ts: killAtLocation now calls handle.killSession directly
instead of opening a stream, signalling it, and corroborating via
listStreams — the REST endpoint's own idempotency replaces that dance.
- sprites-client.ts (apps/web) / realtime-sprites-client.ts (apps/realtime):
wrap raw SDK Sprite instances with killSession via killSpriteSession.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013mYrPXsCJFegYupazDEDGe
The kill endpoint was called at /v1/sprites/{name}/exec/sessions/{id}/kill,
but the documented (and real) path is /v1/sprites/{name}/exec/{session_id}/kill
(sprites.dev/api/sprites/exec#kill-exec-session) — no "sessions/" segment.
Every real kill 404'd against the wrong route, and killSpriteSession's own
404-is-idempotent-success handling silently swallowed it: rows/panes were
torn down client-side while the server-side session kept running and billing.
Also: a 200 response is streaming NDJSON progress (signal -> exited/killed ->
complete, or a mid-stream `error` line if the signal couldn't be delivered) —
the HTTP status alone doesn't confirm the kill succeeded. killSpriteSession
now drains the body and rejects on any `type: 'error'` line via the new pure
killSessionStreamErrorMessage helper, and the whole call (request + drain) is
now bounded by a 10s timeout via AbortController, consistent with every other
network call this driver makes (filesystem ops, checkpoints).
Fixes a P1 correctness finding from automated review on PR #2050.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013mYrPXsCJFegYupazDEDGe
… rename trigger Proactive convergence pass addressing findings from a multi-angle automated review of PR #2050: - killSpriteSession now retries (MAX_EXEC_ATTEMPTS, linear backoff, same schedule as withWakeRetry) on ANY failure — not just structurally-detected pre-open drops like the exec/spawn paths in this file. A kill is idempotent by construction (404/410 already means success), so blanket retry is safe and closes a real gap: the fire-and-forget kill from PtyShell.kill() and the DB-row-keeping kill from killAtLocation both previously gave up after one attempt, even against a transient failure exactly like the cold-Sprite wake-on-request drop the exec paths already guard against. - Hoisted the `withKillSession` SDK-wrapping bolt-on (three independent reviewers flagged the same near-verbatim duplication) into a single exported `withKillSession` in sprites.ts, used by both apps/web/src/lib/ sandbox/sprites-client.ts and apps/realtime/src/terminal/ realtime-sprites-client.ts instead of each defining its own copy. - Renamed the `TeardownTrigger` value `'user-kill'` to `'forced-teardown'`. Its only real callers (teardownAgentTerminalSession, on revoked access or an insolvent payer) are the platform ending a session, never a literal user click — the actual user-facing kill path (killAgentTerminal) doesn't go through PtyShell/TeardownTrigger at all. The old name would read, in an incident review, as "the user did this." - Documented why `detach`/`shell-exit` have no production call site today (both are part of planTeardown's tested decision matrix, not dead code) and fixed a stale comment in sprite-machine-host.ts's stream() doc that still claimed to protect killAgentTerminal, which no longer calls stream(). Verification: packages/lib 8029 tests, apps/realtime 825 tests, full monorepo typecheck (16/16) — all green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013mYrPXsCJFegYupazDEDGe
The Sprites exec kill endpoint (POST .../exec/{session_id}/kill)
defaults to SIGTERM when the signal query param is omitted. The old
WS-based kill path this replaces always sent SIGKILL, so a process
that traps/ignores TERM would survive a default-signal call while
killSpriteSession still reports success — the row/session bookkeeping
gets removed out from under a still-live, still-billable session.
Explicitly request ?signal=SIGKILL to match the old behavior.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013mYrPXsCJFegYupazDEDGe
979cc4c to
3ca3b3f
Compare
master gained a new required MachineHandle.killSession method (PR #2050, "Real session termination via the kill endpoint") while this branch was in flight. makeFakeHost's makeHandle already picked up the new field via the merge; makeRootHandle (a separate literal MachineHandle used only by the credential-propagation tests) did not and needed it added by hand. packages/lib: 357 files / 8189 tests pass. apps/realtime: 23 files / 832 tests pass. Full monorepo typecheck (16 packages) clean.
… Sprites (#2054) * feat(machines): propagate Claude Code credential into branch-terminal Sprites Each branch-terminal is a physically separate Sprite from its owning Machine, with its own persistent /home/sprite — so Claude Code's OAuth credential (written to /home/sprite/.claude/.credentials.json on whichever Sprite ran `claude`) never reaches a freshly provisioned or reattached branch Sprite. Add resolveRootMachineHandle to MachineBranchesDeps and copy the root Sprite's .credentials.json (and .claude.json config, if present) into the branch Sprite at spawnBranch (both first-provision and warm reattach) and at attachBranch — refreshed on every reattach, not just once, since Claude Code credentials rotate. Gracefully skips when the root Machine has no live session or credential yet; any copy failure is swallowed as best-effort so it never fails the branch spawn/attach itself. Wired in machine-branches-runtime.ts via a direct machine_sessions lookup by pageId (same precedented pattern as machine-storage-billing.ts), avoiding acquireMachineSession's full re-authz + provision-fresh flow since this only ever needs read access to an existing session. * fix(machines): refresh Claude credential on the real branch PTY attach path Codex review on PR #2054 correctly flagged that spawnBranch/attachBranch (machine-branches.ts) are NOT the path a user's branch agent terminal actually opens or reattaches through — that's realtime's resolveAgentTerminalSandbox -> resolveMachineSandbox (agent-terminal-access.ts), which resolves the branch Sprite directly and never calls machine-branches.ts at all. So the credential-copy added in the prior commit only fired on branch creation or an explicit POST /api/machines/branches, leaving a branch opened via its agent terminal (the common case) with a missing or stale credential. - Export propagateClaudeCredential from machine-branches.ts for reuse. - Add findLiveMachineSandboxId to machine-session-manager.ts: a read-only pageId -> sandboxId lookup (same precedented direct query machine-storage-billing.ts already uses), shared by both machine-branches-runtime.ts's resolveRootMachineHandle (deduping its prior inline query) and the new realtime wiring. - Add an optional refreshBranchCredential dep to resolveMachineSandbox (agent-terminal-access.ts), invoked only for branch-scope targets (machine/project scope already run ON the root Sprite). Wrapped in try/catch at both the call site and the dep's own implementation (index.ts) — a credential refresh must never block or fail opening the PTY itself. - Wire it in apps/realtime/src/index.ts's resolveAgentTerminalSandbox, reusing the connect's existing Sprite handle cache so only the root Sprite's read is a genuinely new network call. Tests: 8 new cases in agent-terminal-access.test.ts covering branch vs machine vs project scope, the dep being optional, and defense-in-depth against a misbehaving implementation that throws. * fix(machines): harden credential-refresh gate and re-secure perms on overwrite Independent second-pass review + CodeRabbit both surfaced real issues in the realtime credential-refresh wiring from the prior commit: - CodeRabbit (Major): writeFiles' `mode` only takes effect at file CREATION — POSIX open()'s mode argument is ignored once a file already exists. A refresh on reattach overwrites an ALREADY-EXISTING credential file, so `mode: 0o600` alone silently leaves whatever permissions that file already had. Added an explicit `chmod 600` exec after each write (via the branch handle's own `exec`, argv-array form — never a shell string) so both the credentials file and the config file are re-secured on every copy, not just the first. Also made both files consistently 0o600 (the config file previously had no explicit mode). - Independent review (worth-fixing): the branch-scope gate in resolveMachineSandbox checked `branchName !== undefined` alone. A real resolveAgentTerminal already rejects branchName-without- projectName as invalid_target before Sprite resolution, so this was unreachable in production — but the gate now explicitly checks BOTH `projectName` and `branchName`, so it stays correct even if a caller wires a resolver that doesn't enforce that invariant (as proven by a new test using a permissive fake resolver). - Independent review (nitpick): hoisted the realtime MachineHost construction to build once per connect instead of once inside the refreshBranchCredential closure — construction-only, no behavior change. New tests: chmod-after-write assertion, and the branchName-without- projectName edge case against a permissive fake resolver. All existing + new tests green (40 in machine-branches.test.ts, 38 in agent-terminal-access.test.ts); packages/lib and apps/realtime typecheck clean. * fix(machines): persist the branch row before the credential copy, not after Codex review caught a real concurrency bug introduced by the credential copy: it ran between a successful clone and the row being persisted, widening that window. A concurrent racer whose OWN clone fails against the same name-keyed shared Sprite calls reconcileProvisionCollision, which looks up the row to decide whether the Sprite it's about to kill is the winner's. With the credential copy's extra I/O (root-Sprite read + branch-Sprite writes/execs) sitting before persistence, the racer could find no matching row yet, conclude the shared Sprite was its own redundant one, and kill it out from under the winner — which was still mid-copy and about to record that exact sandboxId. Fix: move propagateClaudeCredential to run AFTER store.updateSandboxId / store.create succeeds, at each success return, instead of once before both branches. The realtime refreshBranchCredential path has no analogous issue — it only ever reads an already-persisted branch row, never creates/updates one. Added two direct regression tests asserting the row is already visible via store.findByName at the exact moment the credential copy starts, for both the fresh-create and the re-provision-after-vanish paths. 42/42 tests pass in machine-branches.test.ts; packages/lib typecheck clean. * fix(machines): resolve current session key, not a stale bare-pageId row; never block PTY open Codex review caught two more real issues on the second follow-up pass: 1. (P1) findLiveMachineSandboxId's bare-`pageId` lookup could return a STALE session row. `machine_sessions.sessionKey` namespaces by tenant + drive + page — a page moved between drives leaves its OLD drive's row behind (a drive move has no reason to touch machine_sessions), so a plain `WHERE pageId = X LIMIT 1` with no ordering could non-deterministically resolve that old row instead of the current one, copying a Claude credential from a Sprite that may belong to a different owner/tenant context entirely. Fixed by having findLiveMachineSandboxId take the same {tenantId, driveId, pageId, secret} the store's OWN session-key derivation uses, deriving the CURRENT sessionKey via deriveMachineSessionKey, and querying by that (the table's actual unique constraint) instead of the bare pageId. Both callers now resolve the page's current driveId + drive owner first: - machine-branches-runtime.ts's resolveRootMachineHandle reuses the existing findMachinePage() plus a new drives.ownerId lookup. - apps/realtime/src/index.ts factors a resolveDriveOwnerContext() helper — the exact two-query lookup buildMachineSandbox.acquire already did inline — shared by both acquire (deduped, no behavior change) and the new refreshBranchCredential wiring. 2. (P2) resolveMachineSandbox awaited refreshBranchCredential before returning. The Sprite fs API's read/write timeout is 30s, with one retry (~60s worst case), so blocking every branch-scope PTY resolution on that would regress this codebase's core invariant that opening a PTY is itself the Sprite's wake and nothing upstream waits on it. Changed to fire-and-forget (void ...catch()) — the refresh still normally lands well before a user manually runs `claude`; the narrower race (typing faster than an in-flight refresh) self-heals once the copy completes. 79 tests pass (42 machine-branches.test.ts + 37 agent-terminal-access.test.ts) under vitest; 101 tests pass in apps/realtime's index.test.ts (unaffected by the acquire dedup); packages/lib, apps/web, and apps/realtime all typecheck clean. * fix(machines): remove stale branch credentials when root's has been cleared Codex review: since propagateClaudeCredential now refreshes on every reattach specifically to keep rotated credentials in sync, treating a now-missing root file as a pure no-op left a branch Sprite silently authenticated with a stale copy after an explicit `claude logout` (or manual removal) on the root Machine. A revocation on the root is exactly the kind of change this sync must also propagate. Changed: when the root's read for a given path comes back empty, exec `rm -f <path>` on the branch handle to remove any stale copy, instead of skipping to the next file. `rm -f` is a harmless no-op when nothing was ever copied (root never logged in). Two new tests: removal on the next reattach after the root's credential disappears, and that removing a never-copied file is a harmless no-op. The fake test host's `exec` now interprets `rm -f <path>` by default (real removal from its in-memory files map) so the first test can assert actual absence, not just the exec call shape. 44/44 tests pass (bun test and vitest); packages/lib typecheck clean. * fix(machines): revert unsafe credential deletion; bound (not fire-and-forget) the PTY-path refresh Codex review caught two more real issues — one in my own immediately preceding commit, one revisiting an earlier tradeoff: 1. Reverted "delete branch credential when root read is empty" (previous commit). The Sprite driver's readFile maps EVERY read failure to the same null a missing file produces (sprites.ts: "a missing file (or any read failure after a wake retry) resolves to null") — there is no way to distinguish a real `claude logout` from a transient root-Sprite read hiccup. Deleting on that ambiguous signal risked destroying a branch's perfectly valid, working credential on a momentary blip — strictly worse than the staleness gap it was meant to close. Back to a pure skip-on-empty, with a regression test guarding against reintroducing the deletion, and the fake test host's now-unused `rm -f` simulation removed. 2. Changed the realtime refreshBranchCredential call from fire-and-forget back to awaited, but now bounded by a tight (5s) timeout instead of either extreme. Fire-and-forget (the prior commit) made propagation unreliable on the primary use case: a fresh branch-scoped Claude terminal calls openShell with `claude` immediately after resolveSandbox returns, so a copy finishing moments later does nothing for a process that already started without the credential. Fully blocking (two commits ago) risked up to ~60s stalls on a cold/hibernating Sprite (30s fs-op timeout, one retry). The bound covers the common warm-Sprite case reliably (normally sub-second) while capping the cold-Sprite worst case to a few seconds instead of a minute. Added a fake-timers test proving the bound actually resolves a never-settling refresh rather than hanging forever. 44 tests pass in machine-branches.test.ts (bun test), 38 in agent-terminal-access.test.ts (vitest — needed for the fake-timers test), packages/lib and apps/realtime typecheck clean. * fix(machines): bound propagateClaudeCredential's own runtime; verify chmod exit code Codex review caught two more real issues: 1. (P2) spawnBranch's and attachBranch's direct calls to propagateClaudeCredential were unbounded — only the realtime PTY path had a timeout (its own withTimeout wrapper around the injected refreshBranchCredential dep, which doesn't help THIS file's own direct callers). A hibernating root Sprite's fs read/write can take up to ~60s (30s timeout, one retry), which would hang a branch-attach HTTP response on credential I/O. Fixed by moving the timeout bound INSIDE propagateClaudeCredential itself (a new CREDENTIAL_COPY_TIMEOUT_MS + withTimeout helper in machine-branches.ts, same never-reject/clear-the-timer shape as agent-terminal-access.ts's), so every caller is protected structurally rather than by each call site remembering to wrap it. 2. (P2) The chmod exec's result was ignored. MachineHandle.exec resolves with an exitCode rather than throwing on a nonzero exit, so a failed chmod (e.g. permission issue) would silently leave an OVERWRITE of an already-existing credential/config file at whatever permissive mode it already had, while this function reported success. Now checks chmod's exitCode and throws (caught by the existing best-effort try/catch, so it still never fails the caller) — a failed chmod aborts the rest of the copy for that call rather than being silently trusted. Two new tests: a failing chmod stops the copy without failing the spawn, and a never-settling resolveRootMachineHandle still returns once the bound elapses (fake timers). 46/46 tests pass via the package's actual `vitest run` test script (this file's new fake-timers test needs it — bun test's vi shim doesn't implement advanceTimersByTimeAsync, same known gap as apps/realtime's index.test.ts). Full packages/lib suite: 349 files / 8026 tests pass. packages/lib and apps/realtime typecheck clean. * fix(machines): fail closed on chmod failure by removing the just-copied credential Codex review: my prior chmod-exit-code fix stopped copying further files on a failed chmod, but left the just-written credential in place at whatever (possibly permissive) mode the destination already had — a real exposure risk on a refresh, since this path runs on every reattach against a file that may already exist with loose permissions. Now attempts `rm -f <path>` on the file we just wrote before throwing, so a failed chmod removes the copy entirely (fail closed) rather than leaving a valid, freshly-refreshed OAuth credential sitting at the wrong permissions. Best-effort — if even the rm fails, the outer catch swallows it same as any other failure in this function. Updated the chmod-failure test to simulate `rm -f` deletion in its custom execImpl and assert the credentials file is actually gone afterward, not just that the config file was never reached. 46/46 tests pass; full packages/lib suite (349 files / 8026 tests) and typecheck both clean. * fix(machines): delete-then-write instead of write-then-chmod, closing the exposure window Codex review: even with the fail-closed chmod-failure cleanup from the prior commit, there was still a real window between writeFiles (the fresh secret lands, at the OLD/possibly-permissive mode) and the follow-up chmod resolving. The outer 5s withTimeout only stops the CALLER from waiting — it doesn't cancel or bound the in-flight work, so a chmod that's slow (but not yet failed) on a flaky Sprite could leave that exposure window open past whatever bound a caller observed, with no cleanup triggered because the chmod hadn't actually failed (or timed out) yet from this function's own perspective. Replaced write-then-chmod with delete-then-write: `rm -f <path>` before every `writeFiles` call. `writeFiles`' `mode` only applies at file CREATION (POSIX open() semantics) — an overwrite of an existing file keeps its old permissions regardless of the requested mode. By deleting first, every write IS a creation, so `mode: 0o600` reliably applies immediately, in the same call — no separate step that can independently fail, stall, or leave a window open. `rm -f` is a no-op on a fresh spawn where nothing was there yet. This removes the chmod-verification and fail-closed-cleanup-on-chmod- failure logic entirely (no longer needed — there's no longer a window it was protecting) along with its two tests, replaced with: one test proving rm happens before write for both files, and one proving a throwing rm exec doesn't fail the spawn (best-effort). 46/46 tests pass (vitest); full packages/lib suite (349 files / 8026 tests) and typecheck both clean. * fix(test): add killSession to makeRootHandle after merging master master gained a new required MachineHandle.killSession method (PR #2050, "Real session termination via the kill endpoint") while this branch was in flight. makeFakeHost's makeHandle already picked up the new field via the merge; makeRootHandle (a separate literal MachineHandle used only by the credential-propagation tests) did not and needed it added by hand. packages/lib: 357 files / 8189 tests pass. apps/realtime: 23 files / 832 tests pass. Full monorepo typecheck (16 packages) clean. * fix(machines): write-to-temp-then-atomic-rename, closing both the exposure and absence windows Codex review: my delete-then-write redesign (previous commit) traded the write-then-chmod exposure window for a worse one — if rm -f succeeds but the following writeFiles then fails (or the outer 5s timeout elapses in between), the branch is left with NO credential at all, even though it had a perfectly valid one moments before. That's a regression on exactly the transient Sprite/FS hiccups this best-effort path exists to tolerate, and it runs on every reattach. Replaced delete-then-write with write-to-temp-then-atomic-rename: write the content to `<path>.tmp` (mode 0o600, a genuine CREATION since the temp path never existed, so the mode reliably applies) then `mv <path>.tmp <path>` to land it at the real destination. `mv` on the same filesystem is atomic — the destination is either the OLD valid credential or the NEW one, NEVER wrong-permission or briefly absent. If anything fails before the rename (including the rename itself), the live file is untouched; on a failed rename, best-effort cleans up the orphaned (already 0600) temp file. Updated the fake test host's default exec to simulate `mv <src> <dst>` (moves the in-memory entry) so existing tests reading the final path still pass. Rewrote the delete-then-write tests as temp-then-rename tests, and added the core regression guard: a failed mv leaves an EXISTING valid credential at the real path completely untouched. 47/47 tests pass (vitest); full packages/lib suite (357 files / 8190 tests) and typecheck both clean. * fix(machines): clear the temp path before writing it, closing the last gap Codex review: the fixed temp path (`<path>.tmp`) isn't guaranteed to be fresh — a prior attempt could have crashed between writing it and renaming it, or its own best-effort cleanup could have failed, leaving a stale temp file behind. Writing to an ALREADY-EXISTING temp path is an overwrite, not a creation, so `writeFiles`' `mode: 0o600` would silently be ignored (same POSIX quirk this whole design exists to work around) — and the subsequent `mv` would then promote that stale file's actual permissions onto the real credential, reintroducing the exact wrong-permissions problem the temp-file flow was built to avoid. Fixed by unconditionally `rm -f`-ing the temp path before writing to it. Safe unconditionally: any failure here only ever touches the disposable temp path, never the live file at the real destination — unlike the reverted delete-then-write design, this never risks the live credential itself. Updated the temp-then-rename test to also assert the rm-then-write- then-mv exec sequence for both files. 47/47 tests pass; full packages/lib suite (357 files / 8190 tests) and typecheck both clean. * fix(machines): check the temp-clear rm's exit code before writing Codex review: the temp-path-clearing `rm -f` result was ignored — if it returned nonzero (unusual for -f, but possible: permission issue, disk error, etc.), the code proceeded to writeFiles anyway, assuming the temp path was now clear. If it wasn't, the write becomes an overwrite rather than a creation, silently keeping the stale file's permissions — reintroducing the exact problem the temp-file flow (and this line's own earlier fix) exists to avoid. Now checks the clear's exitCode and throws (caught by the existing best-effort try/catch) before ever writing to the temp path if it failed. Still safe either way: nothing has touched the live file yet at that point. Added a test proving a failing temp-clear aborts before writeFiles/mv ever run, leaving an existing valid credential at the real path untouched. 48/48 tests pass; full packages/lib suite (357 files / 8191 tests) and typecheck both clean. * fix(machines): prevent a stalled overlapping copy from clobbering a newer one Codex review: withTimeout deliberately keeps a slow call's underlying work running in the background past its own bound (so a slow copy still lands rather than being aborted). That means an overlapping call that read an OLDER root credential and then stalled could finish LATER and mv its stale temp file over a destination a faster, more recent call already updated with a NEWER (rotated) credential — clobbering it. This defeats refresh-on-reattach specifically when Sprite FS calls are slow enough to hit the 5s bound. Initial attempt (in-flight Set + skip-if-already-running) had its own correctness gap: if a call's underlying work never settles at all (e.g. a resolveRootMachineHandle that hangs forever), the in-flight marker would never clear, permanently locking that branch out of all FUTURE refreshes too — a worse regression than the one being fixed. Replaced with a generation counter per branch Sprite machineId: each propagateClaudeCredential call takes a ticket number, and checks (after writing its temp file, right before the risky mv) whether a NEWER call has since started for the same branch — if so, it abandons its own stale attempt instead of committing it. No lock to release, no backstop timer, no permanent-lockout risk: the check is self-contained and only matters at the one moment it's unsafe to proceed. Added a test that directly reproduces the race: an overlapping call with a manually-held-open root resolution (simulating a stall), racing against a normal call with a rotated token — proves the stalled call detects the newer generation and skips its mv rather than clobbering it. 49/49 tests pass; full packages/lib suite (357 files / 8192 tests) and typecheck both clean. * fix(machines): use a per-generation temp path, not a fixed shared one Codex review: my generation-counter fix (previous commit) still had both overlapping calls writing to the SAME fixed temp path (`<path>.tmp`). If an older call's stale-cleanup ran AFTER a newer, overlapping call had already written its OWN content to that shared path (but before the newer call's own mv), the older call's cleanup deleted the newer call's temp file out from under it — making the newer (correct) refresh fail its own mv and never propagate at all. That's worse than the clobber being fixed: the correct refresh now silently fails instead of the stale one being merely skipped. Fixed by suffixing the temp path with the call's own generation number (`<path>.tmp.<generation>`) — no two overlapping calls for the same branch Sprite ever share a temp filename, so one's cleanup can never disturb another's in-progress write. Still clears the temp path first regardless: `generation` resets to 1 on every process restart (in-memory, not persisted), so a stale leftover from a pre-restart crash could coincidentally reuse the same generation number. Updated the temp-path assertions to pattern-match `.tmp.\d+` (the generation number isn't predictable — it's a module-level counter shared across the whole test file, not reset per test) instead of the old fixed-suffix literal. 49/49 tests pass; full packages/lib suite (357 files / 8192 tests) and typecheck both clean. * fix(machines): globally-unique temp path (cuid2) — the generation counter is process-local Codex review: apps/web (spawnBranch/attachBranch) and apps/realtime (refreshBranchCredential) are TWO SEPARATE PROCESSES, each with its own in-memory latestCredentialCopyGeneration Map. A call in one process cannot see an overlapping call in the other — both would independently compute generation=1 and collide on the same `<path>.tmp.1`, reintroducing the exact "one call's cleanup deletes another's in-progress temp file" bug the per-generation temp path was supposed to close, just across the process boundary instead of within one process. Fixed by keying the temp path on a globally-unique cuid2 id (`createId()`, matching this codebase's established convention for non-DB-primary-key identifiers) instead of the process-local generation number. No two overlapping calls, from either process, can ever share a temp filename now. The generation counter itself remains process-local by nature — fully solving cross-process staleness detection (which credential's `mv` should "win") would need a database-backed lock or a version marker persisted on the Sprite's own filesystem, which is disproportionate for a background sync where every single refresh is already best-effort and self-heals on the next reattach in either process. Documented this explicitly as an accepted, understood tradeoff rather than leaving it implicit. The within-process race (e.g. two nearly simultaneous requests on the same server) is still fully closed. Updated test regexes from `\d+` to `[a-z0-9]+` to match cuid2's alphanumeric ids instead of a decimal counter. 49/49 tests pass; full packages/lib suite (357 files / 8192 tests) and typecheck both clean. * fix(machines): bound every housekeeping rm/mv exec with an explicit timeoutMs Codex review: the Sprite runner only installs its SIGKILL wall-clock timer when timeoutMs is explicitly supplied (sprites.ts's spawn path gates the kill timer on `timeoutMs && timeoutMs > 0`). None of this function's rm/mv exec calls passed one, so on a cold or wedged Sprite they were genuinely unbounded at the transport level — the 5s CREDENTIAL_COPY_TIMEOUT_MS only stops propagateClaudeCredential's CALLER from waiting, it does not touch the exec itself. A wedged rm or mv would never be killed, leaking its process/socket on every such attach attempt. Added a housekeepingExecArgs helper (timeoutMs: the same 5s bound, maxBytes: the existing SANDBOX_MAX_OUTPUT_BYTES constant already used elsewhere for sandbox commands) applied to all four rm/mv exec calls in propagateClaudeCredential. Added a test asserting every rm/mv exec carries a positive timeoutMs and maxBytes. 50/50 tests pass; full packages/lib suite (357 files / 8193 tests) and typecheck both clean. * docs(machines): document the remaining pre-mv/mv race as a deliberate, accepted tradeoff Codex review (raised twice now): the generation check happens before the mv, not atomically with it, so a newer overlapping call could still start, finish its own mv, and then have this call's own (checked-as-current-a-moment-ago) mv land after it, clobbering the fresher credential. This is real and not being fixed further. Closing it fully would need either an atomic compare-and-swap this filesystem abstraction doesn't expose, or a synchronous per-branch mutex serializing every mv — which would queue an unrelated caller behind however long a DIFFERENT in-flight refresh's Sprite I/O takes, reintroducing the unbounded-latency problem this whole design exists to avoid. The impact is staleness (the branch keeps using an old-but-still-valid token a little longer), not exposure, and self-heals on the next reattach in either process — consistent with every other best-effort guarantee already documented in this function. Added an explicit code comment at the check site recording this as a deliberate, reasoned tradeoff rather than an oversight, so it reads as settled if raised again. No behavior change; comment-only.
Summary
Every teardown path called
kill('SIGKILL')on the client WebSocket handle — but TTY sessions havemax_run_after_disconnect: 0and keep running server-side after the socket closes. Nothing in the codebase called the documented kill endpoint (POST /v1/sprites/{name}/exec/{session_id}/kill), so "killed" panes could leak live sessions that later reattach paths rediscover and cross-wire.This wires the real endpoint into both places that need genuine termination — the explicit
killAgentTerminalAPI and the 30-min detached-idle reap — while preserving detach-never-terminates semantics.Branched from current master (post #2038/#2039/#2041/#2042/#2031); verified against https://sprites.dev/api.
Requirements
Given a user-initiated terminal kill, should call the sessions kill endpoint for the exact session id AND close the socket.
packages/lib/src/services/machines/agent-terminals.ts'skillAtLocationnow callshandle.killSession(row.streamSessionId)— a direct REST call, replacing the old open-a-stream-then-signal-then-corroborate-via-listStreams dance (that dance existed only because a WS signal is unreliable; the REST endpoint answers authoritatively on its own).Given a viewer detach (navigate away), should close/abandon the client socket only — never kill the session.
sprites-shell.ts's newplanTeardown({trigger: 'detach'})returns{killSession: false, closeSocket: false}.agent-terminal-handler.ts'sdisconnectConnectionmain body (immediate detach, not the idle timer) never callsPtyShell.kill()at all — it only callssetViewerAttached(false), which stops the reconnect watchdog without touching the exec session. Verified this can never regress into a kill viaplanTeardown's pure matrix tests plusshell.kill('detach')integration tests.Given the detached-idle reap (30-min timer), should kill the server-side session.
agent-terminal-handler.ts'sdisconnectConnectionidle timer now callssession.command.kill('idle-reap'), whichplanTeardownmaps to{killSession: true, closeSocket: true}—PtyShell.killcallssprite.killSession(currentSessionId)(works even though the exec socket is almost certainly already dead by then, since detach stopped reconnecting) plus a best-effort localcurrent.kill('SIGKILL').Given a kill against an already-dead session id, should succeed idempotently.
sprites.ts'skillSpriteSessiontreats HTTP 404/410 as success rather than throwing (the documented response for a missing session — verified against the "Attach to Exec Session"/"List Exec Sessions" endpoints, which share the resource family). Covered end-to-end:killAgentTerminalagainst a danglingstreamSessionIdstill drops the row;MachineHandle.killSession/SpriteInstanceLike.killSessioninherit the idempotency.Design notes
planTeardown({trigger: 'forced-teardown' | 'detach' | 'idle-reap' | 'shell-exit'}) -> {killSession, closeSocket}insprites-shell.ts, fully unit-tested (all 4 branches). Onlyforced-teardownandidle-reaphave a real call site today (agent-terminal-handler.ts) —detach/shell-exitcomplete the tested decision matrix but aren't wired anywhere yet (a plain detach never callskill()at all; a real shell exit is handled byfatal(), which also never callskill()since the process already ended). Documented explicitly so this doesn't read as dead code.@fly/spritesSDK (rc37) exposes no session-kill-by-id — onlyattachSession/createSession/kill()(a per-command WS signal, which no-ops once the socket is closed).killSpriteSessionhitsPOST /v1/sprites/{name}/exec/{session_id}/killdirectly using the realSpritesClient's publicbaseURL/tokenfields.withKillSession(shared insprites.ts) bolts the method onto a raw SDKSpriteinstance; both app boundaries (apps/web/src/lib/sandbox/sprites-client.ts,apps/realtime/src/terminal/realtime-sprites-client.ts) call it instead of each defining their own copy.200response is streaming NDJSON progress (signal→exited/killed→complete, or a mid-streamtype: 'error'line if the signal couldn't actually be delivered) — the HTTP status alone doesn't confirm the kill succeeded.killSpriteSessiondrains the body and rejects on anyerrorline (purekillSessionStreamErrorMessagehelper).killSpriteSessionretries up toMAX_EXEC_ATTEMPTS(3, linear backoff — the same schedulewithWakeRetryuses) on any failure, not just a structurally-detected pre-open drop like the exec/spawn paths in this file require. A kill is idempotent by construction (see the 404/410 handling), so blanket retry is safe and closes a real reliability gap: both the fire-and-forget kill inPtyShell.kill()and the DB-row-keeping kill inkillAtLocationpreviously gave up after one attempt, even against a transient failure like the cold-Sprite wake-on-request connection drop the exec paths already guard against. Each attempt is bounded by a 10sAbortControllertimeout, matching this driver's existing pattern for every other network call (filesystem ops viawithTimeout, checkpoints viaCHECKPOINT_TIMEOUT_MS).MachineHandlegainedkillSession(sessionId), backed by the same REST call viasdk.getSprite(...).killSession(...)insprite-machine-host.ts. Itsstream()method's wake-retry doc used to describe protectingkillAgentTerminal— now stale (that caller no longer usesstream()), so the comment was corrected;stream()/listStreams()currently have no production caller at all (kept as the general primitiveMachineHandlepromises, per the file header).?signal=SIGKILL— the endpoint defaults toSIGTERMwhen the query param is omitted, which would let a process trapping/ignoring TERM survive a call that still reports success. Both real call sites (killAtLocation's explicit kill andPtyShell.kill's idle-reap) go throughattemptKillSpriteSession, so both inherit this for free.Test evidence
New/changed tests:
sprites.test.ts:killSpriteSession(correct URL shape + encoding + explicitsignal=SIGKILL, NDJSON success/error-line draining, 404/410 idempotency, retry-then-succeed, retry-exhaustion, backoff schedule),killSessionStreamErrorMessage(pure NDJSON-line matrix),withKillSession(bolt-on delegation, other methods untouched)sprites-shell.test.ts:planTeardownmatrix +shell.kill(trigger)integration (forced-teardown/idle-reap kill by id, detach/shell-exit never touch the session, idempotent double-kill, kill-failure doesn't throw)agent-terminal-handler.test.ts: trigger assertions on the two real call sitessprite-machine-host.test.ts:MachineHandle.killSessiondelegation + idempotencyagent-terminals.test.ts: rewrotekillAgentTerminal/killAgentTerminalByIdsuites for the simplified REST-call path (dropped the now-obsolete transient-stream-failure/listing-corroboration tests, added a dangling-session idempotency test)Out of scope (per leaf spec)
Grace-window length; sprite destroy semantics (6-2). Also considered and deliberately NOT done (see PR discussion for full reasoning): removing the now-callerless
MachineHandle.stream()/listStreams()methods (kept as general infrastructure); reusingpackages/lib/src/utils/fetch-with-timeout.tsfor the kill call's timeout (would require adding fetch-injection to an otherwise-unrelated, currently-unused shared utility); eliminating the extrasdk.getSprite()round trip inkillAtLocation's attach-then-kill sequence (architectural consistency with every otherMachineHandleoperation outweighs the marginal latency win on a non-hot path).🤖 Generated with Claude Code
https://claude.ai/code/session_013mYrPXsCJFegYupazDEDGe