feat(agents): launch, stop and relaunch the runner in the sandbox

Merged
CH
5d ago
+2265-26

P08 of the rollout in "Bringing Codex or Claude Code Max plans into Luke" (https://tryluke.dev/stage/plan/89f19bfc-88b5-471a-886a-84b00180aa09). Adds the claude_code implementation of AgentSessions. Nothing routes to it yet (that's P11), so nothing reaches users.

What it does

packages/service/src/agents/coder-host/claude-code-sessions.ts, claudeCodeSessions(options):

  • open
    • Opens a persistent Vercel Sandbox from the coding agent's image and tags it sessionId:<Luke-minted id>. The Terminal tab, the stop and the cleanup sweep find it the same way they find eve's. P11 stores this id in runtime_session_id.
    • Checks the repository out exactly as eve's sandbox does today (checkOutAgentRepository, same token at the firewall). An agent with no repository gets the empty working folder.
    • Mints the runner's bearer and records its SHA-256 with recordCodingAgentRunnerToken.
    • Uploads P05's bundle as the default user.
    • Starts a detached command with sudo: true that:
      • installs the bundle root-owned under /opt/luke/agent-runner and checks it against Luke's digest (in argv; not a secret);
      • marks the checkout safe.directory and shares it with agent's group;
      • execs setpriv --reuid=agent … --inh-caps=-all --ambient-caps=-all --bounding-set=-all into the runner.
    • The plan token (from the key store's claude_setup_token row) and the bearer are passed only in that command's env. In test mode, an Anthropic API key (testApiKey) replaces the plan token. Neither appears in argv, the config line, a file, or a tag.
    • Writes the config line to the runner's stdin: harness, executable = the image's claude, the model, effort, append, firstSequence, the events and MCP URLs, allowedDomains. The first message line follows it.
    • If anything after the create fails, the sandbox is deleted again.
  • send
    • Writes a message line to the running runner.
    • If the sandbox stopped or the runner exited, it resumes the sandbox, sets the repository credential at the firewall again, and relaunches with resume = native_session and the next firstSequence. Each launch gets a fresh bearer.
  • cancel writes stop.
  • reset retires the bearer (recordCodingAgentRunnerToken(…, null)) and deletes the sandbox.
  • stream is empty. The runner posts its events itself.
  • runnerEnded({ target, sessionId, sequence }) is for P09's route on END:
    • It stops the sandbox, taking the firewall credential off first, unless a Terminal lease holds it.
    • An END from a launch that a newer one replaced does nothing. It's recognized by its sequence being lower than the launch's first, which is kept in a sandbox tag.
    • Under P05's exit rule a waiting runner posts no END, so its sandbox stays up.

The runner's command id, the sandbox session it runs in, and the launch's first sequence are kept as sandbox tags. That's how a later call (a separate function invocation) finds the runner to write to. This adds five tags, which is Vercel's maximum.

Supporting changes

  • Runner (@luke/agent-runner)
    • A message line may carry uuid (Luke's line id). The runner uses it as the SDK prompt's uuid and posts luke_runner_accepted { uuid } just before handing the prompt to the SDK. P09 matches the awaiting row on it; the shape was agreed with P09.
    • A test-mode ANTHROPIC_API_KEY is scrubbed from events like the plan token.
    • EveMessage gains an optional lineId, which eve never sees.
  • vercel-sandbox-api.ts: the calls from @vercel/sandbox 3.8.0 that the 3.3.0 copy lacks. These are create (from a snapshot, on v3), updateTags, writeFiles (gzipped tar), detached start with attachStdin, writeStdin (offset-safe, 512 KiB chunks), commandExit and kill, plus env on run.
  • Image seam (AgentSandboxImage), filled today from LUKE_AGENT_SANDBOX_SNAPSHOT through the environment reader. When it's unset, open answers failed and opens nothing.
    • eve exposes its prepared snapshot id to no code of Luke's, and an eve app serves no custom route, so this is option (d), agreed with the orchestrator.
    • ⚠️ A hand-set snapshot id goes stale every time PREPARE_SCRIPT changes the image. It only carries us to Gate B. A follow-up must replace it, most likely with Luke's own template keyed by the LUKE_SANDBOX_IMAGE digest, before P11's workflow runs on Preview. There's a TODO at the seam.
  • Runner bundle
    • The web build now runs pnpm --filter @luke/agent-runner build first.
    • bundle-functions.ts fails if agent-runner.mjs is missing. It copies the bundle into every .func whose bundle reaches runner-bundle.ts, which runnerBundleFromDirectory reads beside the entry. No function reaches it yet; P11's will.
  • Left to the composer, P11: the events URL, MCP URL, instructions and nextSequence are options. P09's readNativeSequence + 1 fills nextSequence. The checkout's promise-shaped RepositorySandbox runs through an edge-handed run, as WebStoreRun does.

Choices made here

  • Command sandbox allowlist: github.com, *.github.com, *.githubusercontent.com, npm and PyPI.
  • Unsupported attachments: an attached file the protocol can't carry (a PDF) is passed as a text note.
  • Model: the Anthropic id comes from providerModelOf.
  • Concurrency: no lock against two concurrent sends relaunching at once. The conversation's existing admission is the guard, as for eve.

Checks

  • Hermetic tests (claude-code-sessions.test.ts) run against a stateful fake of the Sandbox REST API over PGlite. They cover:
    • launch env and config, with no secret in argv, config, tags or the upload;
    • the plan token opened from a sealed claude_setup_token row;
    • no snapshot or no token → failed, and the sandbox deleted;
    • send and cancel;
    • relaunch with resume and the right firstSequence after a sandbox stop, with a fresh bearer;
    • relaunch after the runner exited;
    • a late END from a replaced launch stops nothing;
    • a Terminal lease keeps the sandbox up;
    • reset deletes the sandbox and retires the bearer.
  • Runner tests cover ACCEPTED, a malformed uuid, and API-key scrubbing.
  • Typecheck (agent-runner, service, web), knip and biome pass locally. The full pnpm check:affected ran out of memory in my sandbox, so CI gates the rest.
  • Preview: the developer waived per-PR Preview runs for this rollout. I did not run it live in a Luke-image sandbox, because this sandbox has no Vercel credentials. So "a Terminal can't read the token" is not checked live here. It rests on P00/P03's finding (agent-owned process, caps dropped for Terminals) and is still owed to the Gate B live run.

Made with Luke