Skip to content

[sprites 2-3] Real session termination via the kill endpoint#2050

Merged
2witstudios merged 4 commits into
masterfrom
pu/sprites-2-3-kill-endpoint
Jul 13, 2026
Merged

[sprites 2-3] Real session termination via the kill endpoint#2050
2witstudios merged 4 commits into
masterfrom
pu/sprites-2-3-kill-endpoint

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary

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 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 killAgentTerminal API 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.

Convergence history: three review passes landed after the initial push — all self-driven, no human review yet requested beyond automated bots. (Commit hashes below reflect the post-rebase history — the branch was rebased onto master after these fixes landed, so earlier hashes cited in review-thread replies predate the rebase but refer to the same changes.)

  1. Codex caught a P1: the kill URL was .../exec/sessions/{id}/kill (extra sessions/ segment, 404s against the real API) instead of the documented .../exec/{session_id}/kill. Since 404 was (correctly) treated as idempotent success, every real kill would have silently no-op'd. Fixed in 0be834f, along with draining the endpoint's streaming NDJSON response body (a 200 alone doesn't confirm the signal was delivered). See the review thread.
  2. A multi-angle self-review (8 independent finder passes) surfaced three more issues, fixed in 1a3b7cb: (a) no retry on transient/cold-wake failures — added, since a kill is idempotent so blanket retry is safe; (b) withKillSession SDK-wrapping duplicated near-verbatim across apps/web/apps/realtime — hoisted into one shared sprites.ts export; (c) the 'user-kill' trigger name was misleading — its only real caller is a platform-forced teardown (revoked access / insolvent payer), never a literal click — renamed to 'forced-teardown'.
  3. Codex caught a second P1: the kill endpoint defaults to SIGTERM when signal is omitted, but the old WS-based kill('SIGKILL') path this replaces always sent SIGKILL. A process that traps/ignores TERM would survive a default-signal call while killSpriteSession still reported success, silently removing row/session bookkeeping out from under a still-live, still-billable session. Fixed in 3ca3b3f by requesting ?signal=SIGKILL explicitly on every kill request. See the review thread.

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's killAtLocation now calls handle.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 new planTeardown({trigger: 'detach'}) returns {killSession: false, closeSocket: false}. agent-terminal-handler.ts's disconnectConnection main body (immediate detach, not the idle timer) never calls PtyShell.kill() at all — it only calls setViewerAttached(false), which stops the reconnect watchdog without touching the exec session. Verified this can never regress into a kill via planTeardown's pure matrix tests plus shell.kill('detach') integration tests.

  • Given the detached-idle reap (30-min timer), should kill the server-side session.
    agent-terminal-handler.ts's disconnectConnection idle timer now calls session.command.kill('idle-reap'), which planTeardown maps to {killSession: true, closeSocket: true}PtyShell.kill calls sprite.killSession(currentSessionId) (works even though the exec socket is almost certainly already dead by then, since detach stopped reconnecting) plus a best-effort local current.kill('SIGKILL').

  • Given a kill against an already-dead session id, should succeed idempotently.
    sprites.ts's killSpriteSession treats 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: killAgentTerminal against a dangling streamSessionId still drops the row; MachineHandle.killSession/SpriteInstanceLike.killSession inherit the idempotency.

Design notes

  • Pure core: planTeardown({trigger: 'forced-teardown' | 'detach' | 'idle-reap' | 'shell-exit'}) -> {killSession, closeSocket} in sprites-shell.ts, fully unit-tested (all 4 branches). Only forced-teardown and idle-reap have a real call site today (agent-terminal-handler.ts) — detach/shell-exit complete the tested decision matrix but aren't wired anywhere yet (a plain detach never calls kill() at all; a real shell exit is handled by fatal(), which also never calls kill() since the process already ended). Documented explicitly so this doesn't read as dead code.
  • The @fly/sprites SDK (rc37) exposes no session-kill-by-id — only attachSession/createSession/kill() (a per-command WS signal, which no-ops once the socket is closed). killSpriteSession hits POST /v1/sprites/{name}/exec/{session_id}/kill directly using the real SpritesClient's public baseURL/token fields. withKillSession (shared in sprites.ts) bolts the method onto a raw SDK Sprite instance; 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.
  • The endpoint's 200 response is streaming NDJSON progress (signalexited/killedcomplete, or a mid-stream type: 'error' line if the signal couldn't actually be delivered) — the HTTP status alone doesn't confirm the kill succeeded. killSpriteSession drains the body and rejects on any error line (pure killSessionStreamErrorMessage helper).
  • killSpriteSession retries up to MAX_EXEC_ATTEMPTS (3, linear backoff — the same schedule withWakeRetry uses) 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 in PtyShell.kill() and the DB-row-keeping kill in killAtLocation previously 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 10s AbortController timeout, matching this driver's existing pattern for every other network call (filesystem ops via withTimeout, checkpoints via CHECKPOINT_TIMEOUT_MS).
  • MachineHandle gained killSession(sessionId), backed by the same REST call via sdk.getSprite(...).killSession(...) in sprite-machine-host.ts. Its stream() method's wake-retry doc used to describe protecting killAgentTerminal — now stale (that caller no longer uses stream()), so the comment was corrected; stream()/listStreams() currently have no production caller at all (kept as the general primitive MachineHandle promises, per the file header).
  • Every kill request explicitly asks for ?signal=SIGKILL — the endpoint defaults to SIGTERM when 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 and PtyShell.kill's idle-reap) go through attemptKillSpriteSession, so both inherit this for free.

Test evidence

packages/lib (sandbox+machines): 49 test files, 1234 tests passed
apps/realtime (terminal):        12 test files, 439 tests passed
bun run typecheck: 16/16 packages clean (includes web build)
bun run lint: clean (pre-existing unrelated warnings only)

New/changed tests:

  • sprites.test.ts: killSpriteSession (correct URL shape + encoding + explicit signal=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: planTeardown matrix + 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 sites
  • sprite-machine-host.test.ts: MachineHandle.killSession delegation + idempotency
  • agent-terminals.test.ts: rewrote killAgentTerminal/killAgentTerminalById suites 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); reusing packages/lib/src/utils/fetch-with-timeout.ts for the kill call's timeout (would require adding fetch-injection to an otherwise-unrelated, currently-unused shared utility); eliminating the extra sdk.getSprite() round trip in killAtLocation's attach-then-kill sequence (architectural consistency with every other MachineHandle operation outweighs the marginal latency win on a non-hot path).

🤖 Generated with Claude Code

https://claude.ai/code/session_013mYrPXsCJFegYupazDEDGe

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 58 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 87e934e2-112c-4278-a4c7-b8216d334572

📥 Commits

Reviewing files that changed from the base of the PR and between f031674 and 3ca3b3f.

📒 Files selected for processing (18)
  • apps/realtime/src/terminal/__tests__/agent-terminal-handler.test.ts
  • apps/realtime/src/terminal/__tests__/sprites-shell.test.ts
  • apps/realtime/src/terminal/agent-terminal-handler.ts
  • apps/realtime/src/terminal/realtime-sprites-client.ts
  • apps/realtime/src/terminal/sprites-shell.ts
  • apps/web/src/lib/sandbox/sprites-client.ts
  • packages/lib/src/services/machines/__tests__/agent-terminals.test.ts
  • packages/lib/src/services/machines/__tests__/machine-branches.test.ts
  • packages/lib/src/services/machines/agent-terminals.ts
  • packages/lib/src/services/sandbox/__tests__/machine-diff.test.ts
  • packages/lib/src/services/sandbox/__tests__/machine-fs.test.ts
  • packages/lib/src/services/sandbox/machine-host.ts
  • packages/lib/src/services/sandbox/sandbox-client/__tests__/machine-host-adapter.test.ts
  • packages/lib/src/services/sandbox/sandbox-client/__tests__/sprite-machine-host.test.ts
  • packages/lib/src/services/sandbox/sandbox-client/__tests__/sprites.test.ts
  • packages/lib/src/services/sandbox/sandbox-client/__tests__/wake-retry.test.ts
  • packages/lib/src/services/sandbox/sandbox-client/sprite-machine-host.ts
  • packages/lib/src/services/sandbox/sandbox-client/sprites.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/sprites-2-3-kill-endpoint

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (signalexited/killedcomplete, 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:

  1. Uses the correct URL (/exec/{session_id}/kill).
  2. Drains the response body on 200 and rejects if any NDJSON line reports type: 'error' (new pure helper killSessionStreamErrorMessage, unit-tested).
  3. Still treats 404/410 as idempotent success — confirmed via the docs' own "Attach to Exec Session" / "List Exec Sessions" endpoints, which document 404 as the response for a session that doesn't exist (the same resource family, so I'm treating that as representative for kill too, since the kill endpoint's own docs list 200/404/500 as its only documented codes).
  4. Added a 10s wall-clock timeout via AbortController around the whole request+drain, matching this driver's existing pattern for every other network call (filesystem ops, checkpoints) — an unbounded raw fetch would'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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

  1. No retry on transient failure. killSpriteSession now 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 from PtyShell.kill() and the DB-row-keeping kill from killAtLocation previously gave up after one attempt, even against the exact cold-Sprite wake-on-request drop this codebase already has dedicated retry machinery for elsewhere.
  2. Duplicated SDK wrapper. The withKillSession bolt-on was copy-pasted near-verbatim across apps/web and apps/realtime's SDK factories. Hoisted into one shared, exported withKillSession in sprites.ts.
  3. 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.
  4. Fixed a now-stale comment in sprite-machine-host.ts's stream() doc that still claimed to protect killAgentTerminal's kill path — that caller no longer uses stream().

Re-verified: packages/lib 8029 tests, apps/realtime 825 tests, full monorepo typecheck (16/16) — all green.

2witstudios added a commit that referenced this pull request Jul 13, 2026
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
2witstudios added a commit that referenced this pull request Jul 13, 2026
… 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
@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review
@codex review

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

@2witstudios: Sounds good — I'll review the changes now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +829 to +830
`${baseURL}/v1/sprites/${encodeURIComponent(spriteName)}/exec/${encodeURIComponent(sessionId)}/kill`,
{ method: 'POST', headers: { Authorization: `Bearer ${token}` }, signal: controller.signal },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

2witstudios and others added 4 commits July 13, 2026 11:33
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
@2witstudios 2witstudios force-pushed the pu/sprites-2-3-kill-endpoint branch from 979cc4c to 3ca3b3f Compare July 13, 2026 16:33
@2witstudios 2witstudios merged commit f140b87 into master Jul 13, 2026
10 checks passed
2witstudios added a commit that referenced this pull request Jul 13, 2026
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.
2witstudios added a commit that referenced this pull request Jul 13, 2026
… 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.
@2witstudios 2witstudios deleted the pu/sprites-2-3-kill-endpoint branch July 14, 2026 12:58
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.

1 participant