Skip to content

Give sandboxed Claude sessions a long-term token on a file descriptor #504

Closed

Description

@barelyworkingcode

User story

As the operator, I want relay to give a long-term Claude token to the sandboxed Claude sessions it starts, so that they stay logged in without a /login and without the token entering any environment.

Acceptance criteria

  • A setting claude_token_file names a file that holds a Claude long-term OAuth token. It is empty by default.
  • With the setting empty, every launch behaves as it does today.
  • With the setting set, each provider Claude session relay starts gets the token on a file descriptor, with CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR naming that descriptor.
  • The same holds for a terminal session from the claude-code template.
  • The descriptor number never collides with the launch pipe the shim already uses.
  • Relay reads the file at each launch. A changed file reaches the next session without a restart.
  • The token never appears in a child's environment, argv, a log line, an audit row, a settings read or an API answer.
  • No sandbox profile grants the token file. A sandboxed process cannot open it by path.
  • A missing or unreadable file fails that launch with an error that names the path and never the content.
  • A sandboxed Claude session reports loggedIn: true with auth method oauth_token, with no /login.
  • The five sandboxed-Claude login journeys in the devbox verify run pass without a manual /login.

What I'd notice

Sandboxed Claude sessions start logged in. The post-relay#503 /login step goes away. A power user sets claude_token_file in settings.json.

Context

  • relay#503 (for relay#181) gave sandboxed Claude a relay-owned config folder. Its login lives there, and one manual /login is needed. Until then the nightly's five sandboxed-Claude screen journeys fail (claude-process#55).
  • A token from claude setup-token has a fixed long lifetime and no refresh rotation. That is the measurement relay#181 option B asked for, so a long session does not go stale the way the refreshed login did.
  • relay#496 lets chosen CLAUDE_CODE_* names through the environment scrub. This issue does not use the environment on purpose, so a token never rides in an env block. It is not a duplicate of relay#496.
  • Claude Code reads the token from a descriptor when CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR is set (2.1.303). Measured on both machines with an empty config dir: loggedIn: true, authMethod: oauth_token.
  • The operator's launchers (007, 007-json) already do this. The token file is ~/.config/claude-ltt/token, mode 600, outside every sandbox grant. It was moved out of ~/.claude because the claude-code template grants ~/.claude read-write.
  • Limit: the Claude process that receives the token, and the tools it runs, can read it. The sandbox protects other sessions and projects, not the session that holds it.
  • Security story (credentials, unattended agents): this issue waits for the owner to name it. The owner named it on 2026-10-19.

Tasks

  • Planner: pick the descriptor number and the settings shape. Write the ## Contract with file ownership and Waits:.
  • Add claude_token_file to settings. Empty by default. Show it in the settings page as a path, never a value.
  • Read the file at launch and pass it on a descriptor to provider Claude sessions and claude-code terminal sessions.
  • Keep the token out of env, argv, logs, audit rows and API answers. Add a test that scans each for the token.
  • Fail closed on a missing or unreadable file, naming the path.
  • Docs: docs/session-host.md ("What a child inherits") and docs/routes.md.
  • Verification task, last: repo-suite, then devboxverify --stack on a slot with the token file at ~/.config/claude-ltt/token, then a real-app check that a sandboxed Claude is logged in and the login journeys pass.

Contract

One PR, branch feat/504-claude-token-fd, base main. Quality check: the story passes (one role, one goal, one reason). Smallest fix: relay reads the file at launch, sends the bytes to relay-sessions in the launch request (as it already sends the identity secret and the model key), and relay-sessions writes them into a pipe whose read end the target gets on fd 5. No new abstraction.

Acceptance criteria (numbered from the story; Q1 and Q2 settle the wording)

  • AC1. A setting claude_token_file names a file that holds a Claude long-term OAuth token. It is empty by default.
  • AC2. With the setting empty, every launch behaves as it does today.
  • AC3. With the setting set, each sandboxed provider claude session relay starts gets the token on fd 5, and CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR=5 is in its environment.
  • AC4. The same holds for a sandboxed terminal launch of the claude-code template (eve card, relay terminal start, relay sandbox claude-code, drop-in).
  • AC5. The token descriptor never shares a number with the shim's identity pipe (fd 3) or status pipe (fd 3 or 4).
  • AC6. Relay reads the file at each launch and each resume. A changed file reaches the next launch without a restart.
  • AC7. The token never appears in a child's environment, its argv, a log line, an audit row, an event, a settings read or an API answer.
  • AC8. Every session sandbox profile denies the token file while the setting is set. A sandboxed process cannot open it by path, also when a template grants a folder that holds it.
  • AC9. A missing, unreadable, empty, oversized or non-regular file refuses that launch with 400 sandbox_unavailable. The message names the path and never the content.
  • AC10. A sandboxed Claude started by relay reports loggedIn: true with authMethod: oauth_token, with no /login, and a second start does too.
  • AC11. The five sandboxed-Claude login journeys (session-agent-state, session-drop-in, session-drop-in-tool-refused, chief-of-staff-send, cos-start) pass without a manual /login.

Descriptor: fd 5

The shim's fixed table is fd 3 (identity secret with --identity, else status) and fd 4 (status with --identity). Fd 5 is the first number no launch pipe uses in any shape, so one number holds end to end: host -> shim -> sandbox-exec -> target. Facts the choice rests on:

  • sandbox-exec closes no descriptor before it execs the target. The profile grants /dev read-write to every session, so /dev/fd/5 opens inside the sandbox too.
  • The shim spawns the target with os/exec and no ExtraFiles today. Go closes no inherited descriptor, so fd 5 would leak through by accident. The contract does not rely on that: the shim hands fd 5 on explicitly (below) and only with --pass-token.
  • A pipe, not an open file of the token path: the child never needs a path grant, it cannot learn the path (fcntl F_GETPATH on an open file returns it), and the bytes are gone once Claude reads them. Claude Code reads a pipe here already (007 uses one). The token is at most 4096 bytes, below the pipe buffer, so the write never blocks.

Interfaces

internal/config/settings.go, on Settings:

// ClaudeTokenFile names a file holding a Claude long-term OAuth token, absolute
// or "~/"-prefixed. Empty: no launch gets a token. omitempty keeps an install
// that never sets it byte-identical.
ClaudeTokenFile string `json:"claude_token_file,omitempty"`

internal/sessions/childenv/childenv.go (stays a stdlib-only leaf):

// Deliberate: the name is CLAUDE_CODE_-prefixed, so IsParentClaudeSession drops a
// copy from relay's own environment. The host adds relay's value after that scrub,
// only to a launch that carries the token. The scrub itself does not change.
const EnvClaudeTokenFD = "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR"

internal/sessions/shim/shim.go:

// TokenFD is fd 5: a pipe the target reads, present iff --pass-token. Above the
// identity pipe (3) and the status pipe (3 or 4), so no launch pipe shares it.
const TokenFD = 5
// Config gains: PassToken bool  (flag --pass-token)
  • ParseArgs refuses --status-fd 5 with --pass-token.
  • runConfig, with --pass-token: fd 5 must be open (fcntl F_GETFD), else stderr and ExitInternal. The shim marks it close-on-exec, never reads it, and sets cmd.ExtraFiles = []*os.File{nil, nil, tokenFile} so the target gets it at fd 5 (nil entries close 3 and 4 in the child). It closes its own copy once cmd.Start returns, success or not.

internal/sessions/hostapi/types.go, on LaunchRequest:

// ClaudeToken is the token relay read from claude_token_file for this launch.
// Set only for a sandboxed claude launch or a sandboxed pty launch of the
// claude-code template. Never logged and never in an error message.
ClaudeToken string `json:"claude_token,omitempty"`
  • dispatch.go: carries it to terminal.CreateSpec.ClaudeToken and session.CreateSpec.ClaudeToken. A token with Sandbox == nil, with a host, or on kind pi, codex or chat answers 400 invalid_spec (claude token requires a sandboxed local claude launch).
  • server.go: the outside-viewer guard also refuses req.ClaudeToken != "".

internal/sessions/terminal (types.go, session.go): CreateSpec.ClaudeToken string. validate() refuses it without Sandbox or with Host. buildShimCmd: when set, os.Pipe, write the token, close the write end, place the read end at shim fd 5 (ExtraFiles padded with nil: [secretR, statusW, tokenR] or [statusW, nil, tokenR]), add --pass-token. buildShimEnv: when set, add[childenv.EnvClaudeTokenFD] = "5" after spec.Env, so relay's value wins.

internal/sessions/provider (shimspawn.go, claude.go): ClaudeConfig.ClaudeToken string and shimSpec.ClaudeToken string; buildShimCmd as the terminal one. Start refuses a token on a spawn that skips the shim (claude token requires the shim). buildClaudeEnv adds EnvClaudeTokenFD=5 when set. logProviderStderr gets the token among its redacted secrets. session.CreateSpec.ClaudeToken (session/manager.go) threads it to ClaudeConfig, never cached across calls (the rule Identity follows).

cmd/relay/session_claude_token.go (new):

const maxClaudeTokenBytes = 4096
// claudeTokenPath: "" when unset; "~/" expanded with os.UserHomeDir; an absolute
// path cleaned; any other value is an error naming the key and value.
func claudeTokenPath(settings *config.Settings) (string, error)
// readClaudeToken opens O_RDONLY|O_NONBLOCK (a FIFO must not block a launch),
// refuses a non-regular file by fstat, reads at most maxClaudeTokenBytes+1,
// trims ASCII whitespace at both ends, refuses empty or oversized.
func readClaudeToken(path string) (string, error)

Errors read claude_token_file <path>: <reason>. The reason is the syscall error (no such file or directory, permission denied) or not a regular file, empty, larger than 4096 bytes. Never the bytes.

cmd/relay/session_launch.go, AuthorizeLaunch: inside the existing sandbox && isClaudeLaunch(req.Kind, tmpl) block, after ensureSandboxClaudeDir: resolve the path and, when set, read the token. Any error returns invalidRequest("sandbox_unavailable", err.Error(), baseFields). Set spec.ClaudeToken once spec exists. It runs before the model key mint and the profile write, so a refusal leaves nothing behind. Relay puts no CLAUDE_CODE_* name in spec.Env; the host sets the variable where it attaches the descriptor, so the two cannot disagree.

cmd/relay/session_sandbox.go, sandboxSpecForLaunch: when claudeTokenPath returns a path, append it to deny for every sandboxed launch, all kinds and templates. Reason: the seeded shell template grants ~ read-write. A path error refuses the launch (sandbox_unavailable), and so does a deny that follows a user-writable link (LinkedDenyError): fail closed.

cmd/relay/overview_seed.go: pathsView gains ClaudeTokenFile string \json:"claude_token_file"`, the stored value, from the settings buildOverviewSeedalready takes.web/src/app.js renderOverviewFooter: when set, one item Claude token file: ; nothing when empty. internal/webassets/settings.html is regenerated (go run ./web/gen`), never hand-edited.

Wire shapes: LaunchRequest.claude_token is the only one. No HTTP or IPC route changes, and no answer gains a field except the Settings window's paths.claude_token_file (a path).

Decisions

  • Gate: no presence gate. This issue adds no write door: the key is set in settings.json only, as session_sandbox.denied_loopback_ports (G14.04) is. A sandboxed session cannot write relay's config dir. A later Settings or API write door must be a presence.GatedOps op, because it chooses a credential relay hands a child.
  • Error code: reuse 400 sandbox_unavailable. The token is part of preparing a sandboxed Claude launch, as the relay#181 folder is (its AC9).
  • Template env: while the setting is set, relay's CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR=5 wins over a template env value. Fail closed: a template number points Claude at a descriptor relay did not fill. While it is empty, a template value passes unchanged, as today.
  • Scope: sandboxed local Claude launches only (Q1). An unsandboxed launch keeps the person's own login. An SSH host launch never gets the token: it would leave the Mac.
  • stack.json also has a key claude_token_file (devboxWorld: the model broker bearer for harness Claude processes). It is a different file with a different meaning; stack-up does not write relay's key, so a stack's Claude keeps the broker. Nothing changes there.
  • Secrets: this PR moves a credential. The coordinator logs it under Secrets. The reviewer runs on the default model (threat-model boundary).

Paths

  • Simple: nothing to do. Once the setting names a file, every sandboxed Claude (eve card, relay sandbox claude-code, Chief of Staff start, drop-in) starts logged in. Settings > Overview shows Claude token file: <path> when it is set. No new screen and no new choice.
  • Power: settings.json "claude_token_file": "~/.config/claude-ltt/token" (absolute or ~/), a mode-600 file written from claude setup-token, outside every grant. A template's env value for the descriptor variable is overridden while the setting is set.

File ownership

Task A (dev, session host):

  • internal/sessions/childenv/childenv.go (constant and package doc)
  • internal/sessions/shim/shim.go
  • internal/sessions/hostapi/types.go, internal/sessions/hostapi/dispatch.go, internal/sessions/hostapi/server.go (viewer guard only)
  • internal/sessions/terminal/types.go, internal/sessions/terminal/session.go
  • internal/sessions/session/manager.go
  • internal/sessions/provider/claude.go, internal/sessions/provider/shimspawn.go

Task B (dev, relay app and docs):

  • internal/config/settings.go
  • cmd/relay/session_claude_token.go (new), cmd/relay/session_launch.go, cmd/relay/session_sandbox.go, cmd/relay/overview_seed.go
  • web/src/app.js, internal/webassets/settings.html (generated)
  • docs/session-host.md: "What a child inherits" (the variable is added after the scrub, only with the token; the token is never in env; the limit that the session holding it, and its tools, can read it), the shim usage line and a step for --pass-token, and the sandbox reach section (the deny).
  • docs/routes.md: POST /api/sessions and POST /api/terminals: sandbox_unavailable also covers an unusable claude_token_file.
  • docs/FEATURES.md: new row G1.45 "Sandboxed Claude gets a long-term token on a descriptor". Simple door none. Power door settings.json claude_token_file. Doors as G1.44. Gate none. Proof event:session.launch=ok#session_id code:http:POST /api/sessions#400. Tests: the names below.
  • docs/THREAT-MODEL.md: attacker 1 gains one sentence: when claude_token_file is set, a sandboxed Claude launch is issued the Claude token on a descriptor, no other launch is, and no sandboxed process can open the file by path. Asset B names the Claude token. The reviewer confirms no other promise moves.
  • docs/architecture.md: the Settings UI Overview footer sentence names the token-file path.

Task C (tests, from the criteria):

  • e2e/features/g1_claude_token_fd_test.go (new; its own start helper on the b181Start pattern; it does not edit the bug181 files)
  • e2e/fakes/fakeagent/main.go: watchedEnv gains CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR and RELAY_SESSION_ID. The start line gains environ (the full os.Environ()), and, when the variable is set, token_sha256 and token_bytes of what it reads from /dev/fd/<n> to EOF (64 KiB cap), or token_error. It never records the bytes.
  • e2e/harness/fakes.go: AgentCall gains those four fields only.
  • docs/testing.md: the fakeagent paragraph names the new fields.

No other file. cmd/devboxverify does not change: no harness change, no gate item.

Tests (task C)

Fake token: acme-fake-token- plus 32 random hex, new per test, in <home>/.config/acme-ltt/token (mode 0600, a trailing newline to prove the trim). The setting is ~/.config/acme-ltt/token, which also proves the ~/ expansion. Compare token_sha256 with the sha256 of the trimmed token.

  1. TestClaudeTokenUnsetLaunchUnchanged: setting empty, template env sets the variable to 9. A session start line has no variable and no token_sha256. A terminal start line has 9 (AC1, AC2).
  2. TestClaudeTokenSessionDescriptor: a sandboxed claude session answers 201, its start line has the variable 5 and the token's sha256 (AC3, AC5: the 201 needs the Hello over fd 3).
  3. TestClaudeTokenTerminalDescriptor: the claude-code terminal with --print-env, same checks (AC4, AC5).
  4. TestClaudeTokenReadAtEachLaunch: launch, rewrite the file with a second token, launch again with no restart. Each start line, told apart by RELAY_SESSION_ID, holds its own token's sha256 (AC6).
  5. TestClaudeTokenNeverDisclosed: a session turn and a terminal launch, the 201 bodies, GET /api/sessions, relay terminal list --json, relay audit --json and every event. Then stop the instance and read every regular file under its home, config and temp folders except the token file. None holds the token. The start lines' environ and argv do not either (AC7).
  6. TestClaudeTokenFileDeniedToSession: the claude-code template also grants ~/.config for reading. In a session, !sh cat <token path> does not answer exit: 0, and the reply does not hold the token (AC8).
  7. TestClaudeTokenFileDeniedToShellTerminal: a sandboxed template that is not claude-code, read_write ~, command /bin/sh -c 'cat "<token path>" > "$TMPDIR/acme-out"; echo $? > "$TMPDIR/acme-rc"'. After its session.exited event, acme-rc is not 0, and neither acme-out nor relay terminal log holds the token (AC8).
  8. TestClaudeTokenMissingFileRefused: the file does not exist. POST /api/sessions answers 400 sandbox_unavailable with a message holding the path. No start line is written for it, and the session_launch audit row is an error (AC9).
  9. TestClaudeTokenUnreadableFileRefused: the file holds the token at mode 000. POST /api/terminals answers 400 sandbox_unavailable. The message and the audit row hold the path, never the token (AC9).
  10. TestClaudeTokenRelayDescriptorWins: setting set, template env sets the variable to 9. The terminal start line has 5 and the token's sha256 (decision: template env).
  11. TestClaudeTokenUnsandboxedNoDescriptor: setting set, claude-code sandbox: false. The start line has no variable and no token_sha256 (Q1).

Break and restore: drop the --pass-token hand-off in the shim; test 2 goes red; restore byte for byte. No red on main: this is an enhancement.

Test surface

  • e2e (integration, CLI and HTTP): AC1 to AC9.
  • Playwright: none; the Settings window is a WKWebView.
  • Real-app pass on the test machine: AC10, and the Overview line from AC1.
  • Screen journeys on the test machine: AC11 (Q3).

Waits

  • Task A: Waits: none; it adds no async work. The token pipe is filled and its write end closed before cmd.Start; the launch still waits on the shim's started status event.
  • Task B: Waits: none; the file read finishes inside AuthorizeLaunch before the host request.
  • Task C: Waits: boot on harness.Start (ready.json) and WaitSessionHost. A launch waits on its 201, a turn on the session message reply (the start line is written before the first reply), a terminal on the session.exited event for its id, a refusal on the HTTP response. The file rewrite in test 4 completes before the second launch request. Test 5 reads files only after i.Stop() returns (relay's process exit). Never a sleep.
  • Task D: Waits: stack-up and devboxverify on process exit; Relay.app on service list showing relaysessions running; the real-app terminals on their session.exited event; the screen run on the done-file its .command writes, read by a background command.

Budget

  • A: 10 files, about 200 lines.
  • B: 12 files, about 260 lines (code about 130, docs about 90, the regenerated bundle).
  • C: 4 files, about 420 lines.
  • Single PR, about 880 lines in 26 files. Past 30 files or 1100 lines is a question for the coordinator.
  • Order: A first (B compiles against LaunchRequest.ClaudeToken); B may start beside it and builds after A's commit; C runs after A and B; D last.

Tasks

  • A: dev, as above.
  • B: dev, as above.
  • C: tests 1 to 11 and the break and restore.
  • D, verification task (last): repo-suite (cd e2e && go test -race ./features/) on the head. devboxverify: unlock the devid keychain, npm ci in an eve main checkout, stack-up r504 --relay <head checkout> --eve <eve main checkout>, go run ./cmd/devboxverify --stack <stack.json> --phase api, stack-down r504. The stack's relay has no claude_token_file, and the five journeys are not in the api phase. real-app, under devlock RESTART SCREEN WORLD: build and install Relay.app from the head, then set "claude_token_file": "~/.config/claude-ltt/token" in the installed app's settings.json (relay reloads it at the next launch). Run relay terminal start --project <Acme Corp id> --template claude-code --extra-arg auth --extra-arg status --json twice, and read each with relay terminal log. Both show "loggedIn": true and "authMethod": "oauth_token". Settings > Overview shows the path line and no token. Then the screen phase (Q3): go run ./cmd/devboxverify --checkout <head worktree> --phase screen from a desktop Terminal through a .command file. The five journeys must PASS; a provider rate-limit BLOCKED reruns after the limit lifts. Leave the setting in place and log it in the run ledger. Post Verified: <head> · repo-suite: … · devboxverify: … · real-app: … · devboxverify-screen: <five ids> … · log: ….

Related open issues (none folded)

  • relay#496 (env allowlist, needs-review): not used; the token never rides in an env.
  • relay#334 (model-backed journeys on a local model): no overlap with login.
  • devboxWorld#3 (bootstrap owns relay verify setup, needs-review): the place for the setting on a rebuilt VM (Q4). Not folded: not approved.

Open questions (recommendation first)

  1. INSIDE. Does AC3 cover unsandboxed claude sessions? No: sandboxed only, as the story and the title say. An unsandboxed Claude reads the person's own login, which works, and the token stays out of launches that do not need it. SSH host launches never get it.
  2. INSIDE. Which value wins when a template's env names CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR? Relay's, only while the setting is set (see Decisions). Empty setting: the template's, unchanged.
  3. BEYOND. AC11 cannot be proven by a stack run: the five journeys are screen-phase only, and --stack refuses --phase screen. Recommendation: one screen-phase run on Relay.app built from the head, with the setting in the app's settings.json, under devlock and the console, judged on the five journeys; a red elsewhere in that phase goes to red-test triage against main. No harness change. Fallback if the coordinator keeps story verification off the screen phase: merge on AC10's real-app check and prove AC11 in the next leased nightly, listed as pending in the report.
  4. BEYOND. The setting lives in the test machine's own Relay.app settings.json, and a VM rebuild loses it. Recommendation: the verification task sets it by hand and logs it; file a devboxWorld idea (needs-review, from-dev) to add it to the VM rebuild steps, linked to devboxWorld#3.
  5. INSIDE. Deny the file for every sandboxed launch, not only Claude ones? Yes: AC8 says no profile, and the seeded shell template grants ~ read-write.
  6. INSIDE. Accept ~/? Yes: a person writes ~/.config/... in settings.json. Any other relative value refuses the launch.

Amendments (review round 1)

  • AC8 covers session sandbox profiles. The MCP broker and git profiles are not sessions and stay unchanged.
  • relay status --json returns paths.claude_token_file, a path only. It reuses the view that the Settings window reads.

Activity

  1. barelyworkingcode commented on Oct 19, 2026

    @barelyworkingcode
    OwnerAuthor

    Coordinator approval: owner asked, 2026-10-19

  2. added a commit that references this issue on Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions