Description
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_filenames 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_DESCRIPTORnaming that descriptor. - The same holds for a terminal session from the
claude-codetemplate. - 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: truewith auth methodoauth_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(forrelay#181) gave sandboxed Claude a relay-owned config folder. Its login lives there, and one manual/loginis needed. Until then the nightly's five sandboxed-Claude screen journeys fail (claude-process#55).- A token from
claude setup-tokenhas a fixed long lifetime and no refresh rotation. That is the measurementrelay#181option B asked for, so a long session does not go stale the way the refreshed login did. relay#496lets chosenCLAUDE_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 ofrelay#496.- Claude Code reads the token from a descriptor when
CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTORis 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~/.claudebecause theclaude-codetemplate grants~/.clauderead-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
## Contractwith file ownership andWaits:. - Add
claude_token_fileto 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-codeterminal 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") anddocs/routes.md. - Verification task, last:
repo-suite, thendevboxverify--stackon a slot with the token file at~/.config/claude-ltt/token, then areal-appcheck 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_filenames 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
claudesession relay starts gets the token on fd 5, andCLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR=5is in its environment. - AC4. The same holds for a sandboxed terminal launch of the
claude-codetemplate (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: truewithauthMethod: 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-execcloses no descriptor before it execs the target. The profile grants/devread-write to every session, so/dev/fd/5opens inside the sandbox too.- The shim spawns the target with
os/execand noExtraFilestoday. 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_GETPATHon an open file returns it), and the bytes are gone once Claude reads them. Claude Code reads a pipe here already (007uses 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)ParseArgsrefuses--status-fd 5with--pass-token.runConfig, with--pass-token: fd 5 must be open (fcntl F_GETFD), else stderr andExitInternal. The shim marks it close-on-exec, never reads it, and setscmd.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 oncecmd.Startreturns, 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 toterminal.CreateSpec.ClaudeTokenandsession.CreateSpec.ClaudeToken. A token withSandbox == nil, with a host, or on kindpi,codexorchatanswers 400invalid_spec(claude token requires a sandboxed local claude launch).server.go: the outside-viewer guard also refusesreq.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.jsononly, assession_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 apresence.GatedOpsop, 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=5wins over a templateenvvalue. 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.jsonalso has a keyclaude_token_file(devboxWorld: the model broker bearer for harness Claude processes). It is a different file with a different meaning;stack-updoes 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 showsClaude 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 fromclaude setup-token, outside every grant. A template'senvvalue 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.gointernal/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.gointernal/sessions/session/manager.gointernal/sessions/provider/claude.go,internal/sessions/provider/shimspawn.go
Task B (dev, relay app and docs):
internal/config/settings.gocmd/relay/session_claude_token.go(new),cmd/relay/session_launch.go,cmd/relay/session_sandbox.go,cmd/relay/overview_seed.goweb/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/sessionsandPOST /api/terminals:sandbox_unavailablealso covers an unusableclaude_token_file.docs/FEATURES.md: new rowG1.45"Sandboxed Claude gets a long-term token on a descriptor". Simple doornone. Power doorsettings.jsonclaude_token_file. Doors asG1.44. Gatenone. Proofevent:session.launch=ok#session_idcode:http:POST /api/sessions#400. Tests: the names below.docs/THREAT-MODEL.md: attacker 1 gains one sentence: whenclaude_token_fileis 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 theb181Startpattern; it does not edit the bug181 files)e2e/fakes/fakeagent/main.go:watchedEnvgainsCLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTORandRELAY_SESSION_ID. The start line gainsenviron(the fullos.Environ()), and, when the variable is set,token_sha256andtoken_bytesof what it reads from/dev/fd/<n>to EOF (64 KiB cap), ortoken_error. It never records the bytes.e2e/harness/fakes.go:AgentCallgains 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.
TestClaudeTokenUnsetLaunchUnchanged: setting empty, template env sets the variable to9. A session start line has no variable and notoken_sha256. A terminal start line has9(AC1, AC2).TestClaudeTokenSessionDescriptor: a sandboxed claude session answers 201, its start line has the variable5and the token's sha256 (AC3, AC5: the 201 needs the Hello over fd 3).TestClaudeTokenTerminalDescriptor: theclaude-codeterminal with--print-env, same checks (AC4, AC5).TestClaudeTokenReadAtEachLaunch: launch, rewrite the file with a second token, launch again with no restart. Each start line, told apart byRELAY_SESSION_ID, holds its own token's sha256 (AC6).TestClaudeTokenNeverDisclosed: a session turn and a terminal launch, the 201 bodies,GET /api/sessions,relay terminal list --json,relay audit --jsonand 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'environandargvdo not either (AC7).TestClaudeTokenFileDeniedToSession: theclaude-codetemplate also grants~/.configfor reading. In a session,!sh cat <token path>does not answerexit: 0, and the reply does not hold the token (AC8).TestClaudeTokenFileDeniedToShellTerminal: a sandboxed template that is notclaude-code,read_write~, command/bin/sh -c 'cat "<token path>" > "$TMPDIR/acme-out"; echo $? > "$TMPDIR/acme-rc"'. After itssession.exitedevent,acme-rcis not0, and neitheracme-outnorrelay terminal logholds the token (AC8).TestClaudeTokenMissingFileRefused: the file does not exist.POST /api/sessionsanswers 400sandbox_unavailablewith a message holding the path. No start line is written for it, and thesession_launchaudit row is an error (AC9).TestClaudeTokenUnreadableFileRefused: the file holds the token at mode 000.POST /api/terminalsanswers 400sandbox_unavailable. The message and the audit row hold the path, never the token (AC9).TestClaudeTokenRelayDescriptorWins: setting set, template env sets the variable to9. The terminal start line has5and the token's sha256 (decision: template env).TestClaudeTokenUnsandboxedNoDescriptor: setting set,claude-codesandbox: false. The start line has no variable and notoken_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'sstartedstatus event. - Task B: Waits: none; the file read finishes inside
AuthorizeLaunchbefore the host request. - Task C: Waits: boot on
harness.Start(ready.json) andWaitSessionHost. A launch waits on its 201, a turn on thesession messagereply (the start line is written before the first reply), a terminal on thesession.exitedevent 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 afteri.Stop()returns (relay's process exit). Never a sleep. - Task D: Waits:
stack-upanddevboxverifyon process exit; Relay.app onservice listshowingrelaysessions running; the real-app terminals on theirsession.exitedevent; the screen run on the done-file its.commandwrites, 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 ciin an evemaincheckout,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 noclaude_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'ssettings.json(relay reloads it at the next launch). Runrelay terminal start --project <Acme Corp id> --template claude-code --extra-arg auth --extra-arg status --jsontwice, and read each withrelay terminal log. Both show"loggedIn": trueand"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 screenfrom a desktop Terminal through a.commandfile. 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. PostVerified: <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)
- INSIDE. Does AC3 cover unsandboxed
claudesessions? 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. - INSIDE. Which value wins when a template's
envnamesCLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR? Relay's, only while the setting is set (see Decisions). Empty setting: the template's, unchanged. - BEYOND. AC11 cannot be proven by a stack run: the five journeys are screen-phase only, and
--stackrefuses--phase screen. Recommendation: one screen-phase run on Relay.app built from the head, with the setting in the app'ssettings.json, under devlock and the console, judged on the five journeys; a red elsewhere in that phase goes to red-test triage againstmain. 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. - 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. - INSIDE. Deny the file for every sandboxed launch, not only Claude ones? Yes: AC8 says no profile, and the seeded
shelltemplate grants~read-write. - INSIDE. Accept
~/? Yes: a person writes~/.config/...insettings.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 --jsonreturnspaths.claude_token_file, a path only. It reuses the view that the Settings window reads.
Activity
barelyworkingcode commentedon Oct 19, 2026
Coordinator approval: owner asked, 2026-10-19
Merge pull request #505 from barelyworkingcode/feat/504-claude-token-fd