Repository navigation
Description
Problem
A Claude session in the relay sandbox loses its login after some hours. Inside the sandbox it cannot re-authenticate. Logging in from an unsandboxed terminal and starting a new sandbox session fixes it. Auto-update also fails in the sandbox.
Root cause
Partly measured.
Measured by running the generated Claude profile (cmd/relay/testdata/golden_sandbox_profile_claude.sb) under sandbox-exec:
| Path | In sandbox |
|---|---|
login keychain, read Claude Code-credentials |
works |
login keychain, write (security add-generic-password) |
Operation not permitted |
~/.claude/.credentials.json |
writable |
~/.local/share/claude/versions, ~/.local/bin |
Operation not permitted (read-only grants) |
Claude Code keeps its OAuth login (claudeAiOauth: access token, refresh token, expiresAt) in both the keychain and ~/.claude/.credentials.json. The access token lasts about 8 hours.
Hypothesis, not yet reproduced: the sandboxed client refreshes the token, the server rotates the refresh token, the keychain write is denied, and the stored refresh token is then dead. A login inside the sandbox fails at the same keychain write.
Auto-update fails because the updater must write the version directory and relink ~/.local/bin/claude. Granting those read-write would let a sandboxed agent replace the binary the next unsandboxed launch runs. Do not grant them.
Fix
Options; needs owner decision: secrets.
- A. Credentials file only. Make the sandboxed Claude read and write
~/.claude/.credentials.jsonand never the keychain, so refresh survives. Open question: does Claude Code fall back to the file after a keychain write fails, or does it prefer the stale keychain copy? Settle this first. - B. Inject a token from relay (
CLAUDE_CODE_OAUTH_TOKEN). Open question: a long-running session may go stale if the injected token does not refresh. Unknown whether it refreshes or expires fixed. Needs a measurement. - C. Grant write on
~/Library/Keychains. Works, but lets the sandbox alter any keychain item. Least preferred. - Auto-update: set
DISABLE_AUTOUPDATER=1in sandbox sessions and update from an unsandboxed terminal.
What I'd notice
Sandbox sessions stay logged in across the 8 hour token lifetime, and no update error appears at start.
Tasks
- Reproduce: sandbox session past token expiry, record where the refresh fails (keychain write vs. file).
- Determine whether the file store is used after a keychain write failure.
- Determine whether an injected token refreshes in a long session.
- Pick A, B or C; update the profile and golden test.
- Set
DISABLE_AUTOUPDATER=1for sandbox sessions.
Contract
Login half only; the updater half shipped in relay#502. Owner decisions of 2026-10-19 are final. One PR, branch fix/181-login-store, base main.
Fix: every sandboxed Claude Code launch gets CLAUDE_CONFIG_DIR=<sandbox Claude folder>, one relay-owned folder shared by every sandboxed Claude on the Mac. Relay creates it (mode 0700) and seeds it with links to the person's ~/.claude entries, never credentials. The profile grants it read-write. Claude then keeps its login in <folder>/.credentials.json. The keychain write that the sandbox refuses no longer leaves a stale item that shadows the file: the owner measured that CLAUDE_CONFIG_DIR=<empty dir> reports loggedIn: false while the default item is readable, so Claude looks up a different keychain item for a non-default folder. Every sandboxed Claude logs out once, until the person runs /login inside one.
Acceptance criteria
- AC1. A sandboxed
claudesession starts withCLAUDE_CONFIG_DIRset to the sandbox Claude folder. - AC2. A sandboxed terminal launch of the
claude-codetemplate starts withCLAUDE_CONFIG_DIRset to the same folder. - AC3. An unsandboxed launch starts without
CLAUDE_CONFIG_DIR, and relay creates no sandbox Claude folder for it. An SSH host launch does not change. - AC4. A
CLAUDE_CONFIG_DIRvalue in the template's ownenvreaches the child unchanged. - AC5. The profile of both launches lets the session write inside the folder, and still refuses a write beside it.
- AC6. Relay creates the folder with mode 0700 and links each listed
~/.claudeentry into it by absolute path. - AC7. The folder holds no
.credentials.jsonand no.claude.json, and no entry in it is a copy of a~/.claudefile. - AC8. Seeding keeps an entry that already exists, link or not, and a second launch changes nothing.
- AC9. When the folder path exists and is not a real directory, the launch is refused with 400
sandbox_unavailable, and the message names the path. - AC10. No keychain grant changes.
Interfaces
internal/sessions/childenv/childenv.go (stays a stdlib-only leaf; update the package doc):
const EnvClaudeConfigDir = "CLAUDE_CONFIG_DIR"
// SandboxClaudeDir is <os.UserConfigDir()>/relay/sandbox-claude: the same
// "relay" folder bridge.DefaultConfigDir names. Deliberately not bridge.ConfigDir:
// it does not move with --config-dir, so every relay instance under one HOME
// (Relay.app, each verify stack) shares one login. An os.UserConfigDir error is
// returned wrapped, never replaced by a fallback.
func SandboxClaudeDir() (string, error)
// SandboxedClaude now takes the folder: {DISABLE_AUTOUPDATER: "1", CLAUDE_CONFIG_DIR: configDir}.
func SandboxedClaude(configDir string) map[string]stringcmd/relay/session_claude_dir.go (new):
// isClaudeLaunch: kind == KindClaude, or kind == KindPTY and tmpl.ID == kindTemplateIDs[KindClaude].
func isClaudeLaunch(kind string, tmpl *config.TerminalTemplate) bool
// ensureSandboxClaudeDir resolves SandboxClaudeDir, creates it and seeds it; returns the folder.
func ensureSandboxClaudeDir() (string, error)ensureSandboxClaudeDir behaviour:
os.MkdirAllthe parent (0700), thenos.Mkdir(dir, 0o700). If it exists,os.Lstatmust report a directory, not a link: otherwise returnsandbox claude dir <path>: not a directory. An existing directory keeps its mode.- For each name in
sandboxClaudeLinks: ifos.Lstat(<dir>/<name>)finds anything, skip it (never overwrite, links included). Target is<home>/.claude/<name>fromos.UserHomeDir, not symlink-resolved.projectsandsessionsget their target created first (MkdirAll, 0700). Every other name is linked only when its target exists.os.Symlink(target, link);EEXISTcounts as seeded (a parallel launch got there first); any other error returnssandbox claude dir <path>: link <name>: <err>. - It never reads, copies or opens a file in
~/.claude.
sandboxClaudeLinks, each with its reason:
settings.json: the person's permissions, hooks, env and model choice, as an unsandboxed Claude sees them.CLAUDE.md: the person's global instructions; a sandboxed Claude reads them today through~/.claude.skills,agents,commands: the person's own skills, subagents and slash commands.projects: transcripts. Relay reads history on join and deletes transcripts under~/.claude/projects(ReadClaudeHistory,DeleteSession), and the outside-session probe reads there too.sessions: Claude's pid registry. The outside-session probe and devboxverify read~/.claude/sessions.- Never linked:
.credentials.json(the login itself);.claude.json, which lives in the folder underCLAUDE_CONFIG_DIRand can hold account data or an API key. Plugins and the rest stay out (Q4).
cmd/relay/session_launch.go, AuthorizeLaunch, right after sandbox is computed: when sandbox && isClaudeLaunch(req.Kind, tmpl), call ensureSandboxClaudeDir; an error returns invalidRequest("sandbox_unavailable", err.Error(), baseFields) (an existing, documented code: audited and evented as today). The PTY block from relay#502 passes that folder to childenv.SandboxedClaude(dir), and a key the template's env sets still wins.
cmd/relay/session_sandbox.go, sandboxSpecForLaunch: when isClaudeLaunch(kind, tmpl), append childenv.SandboxClaudeDir() to readWrite, beside the pi leaf. This is not demoted in a read-only-projects session, because it is not a project. sandboxWritableRoots needs no change: the folder is under home, which is always in W.
internal/sessions/provider/claude.go, Start (console spawn): when p.cfg.SandboxProfile != "", resolve childenv.SandboxClaudeDir(). An error fails the start with sandboxed claude: %w. buildClaudeEnv merges childenv.SandboxedClaude(dir). Relay already created the folder in AuthorizeLaunch (a resume goes through it too), so the provider creates nothing.
Wire shapes: none change. No hostapi field; both processes resolve the same function under the same HOME, and the e2e path assertions catch any drift.
Paths
- Simple: the person opens a Claude Code terminal (eve terminal card,
relay sandbox claude-code) or starts a Claude session. The first sandboxed Claude asks for a login. The person runs/loginonce inside it, and every later sandboxed Claude on the Mac shares that login and refreshes it. No new screen, no new choice. - Power:
settings.jsonterminal_templates[]claude-codeenv.CLAUDE_CONFIG_DIRpoints a terminal at another folder, which the operator must also grant inread_write. Aclaudesession reads no template env, so it has no power door. That is deliberate: one store for every session.
File ownership
Dev task (A):
internal/sessions/childenv/childenv.gointernal/sessions/provider/claude.gocmd/relay/session_claude_dir.go(new)cmd/relay/session_launch.gocmd/relay/session_sandbox.gointernal/sessions/sandbox/sandbox.go: package comment only. The~/Library/Keychainssentence no longer holds for Claude's login; it becomes: kept readable, but the login no longer depends on it.docs/session-host.md: a new subsection after the updater paragraph covering the folder, who makes it, the link list,/loginonce, the keychain untouched, template env wins, and unsandboxed and SSH host launches unchanged. Also extend the "Every session" grant row: a Claude launch also gets the sandbox Claude folder.docs/FEATURES.md: a new rowG1.44"Sandboxed Claude keeps its own login". Simple door:none(turns on by itself;/loginonce). Power door:settings.jsonterminal_templates[].env. Doors as G1.43. Proof:event:session.launch=ok#session_idcode:http:POST /api/sessions#400. Tests: the nine names below.
Test task (B):
e2e/features/bug181_sandboxed_claude_login_test.go(new; reusesb181Startandb181Terminalfrom the updater file, which it does not edit)e2e/fakes/fakeagent/main.go: addCLAUDE_CONFIG_DIRtowatchedEnv, nothing elsedocs/testing.md: the watched-variable sentence namesCLAUDE_CONFIG_DIR
No other file. docs/THREAT-MODEL.md does not change: a sandboxed Claude reaches a Claude login it already reaches today (keychain read, ~/.claude read-write), now in a relay-owned leaf. No promise moves; the reviewer confirms. No golden profile test exists (relay has no unit tests); AC5 is proven by behaviour.
Tests (task B), expected folder W = <i.Home>/Library/Application Support/relay/sandbox-claude
TestBug181SandboxedClaudeSessionConfigDir: the session's start line hasCLAUDE_CONFIG_DIR == W(AC1).TestBug181SandboxedClaudeTerminalConfigDir:b181Terminalenv hasCLAUDE_CONFIG_DIR == W(AC2).TestBug181UnsandboxedClaudeNoConfigDir: withsandbox:false, the key is unset andWdoes not exist (AC3).TestBug181TemplateConfigDirWins: template envCLAUDE_CONFIG_DIR=<i.Tmp>/acme-claudereaches the child as that value (AC4).TestBug181SandboxClaudeDirWritable: in a session,!sh touch "<W>/acme-probe"answersexit: 0and the file exists.!sh touch "<i.Home>/Library/Application Support/relay/acme-outside"does not answerexit: 0, and no file appears (AC5). Quote the paths: they contain a space.TestBug181SandboxClaudeTerminalDirWritable: override theclaude-codetemplate's command with/bin/sh -c 'touch "$CLAUDE_CONFIG_DIR/acme-probe"'.relay sandbox claude-codefrom the project folder exits 0 and the file exists inW(AC2, AC5).TestBug181SandboxClaudeDirSeeded: the fixture~/.claudeholdssettings.json,CLAUDE.md,skills/,agents/,commands/, plus a.credentials.jsonand a~/.claude.jsonof placeholder bytes (acme-placeholder, never a token). After one session turn:Wis mode 0700. Each of the seven names is a link whoseReadlinkis<i.Home>/.claude/<name>, and theprojectsandsessionstargets are directories.Wholds no.credentials.jsonand no.claude.json. No regular file inWholds the placeholder bytes, and every entry inWis one of the seven (AC6, AC7).TestBug181SandboxClaudeDirKeepsOwnEntries:Wexists before the launch with a regularsettings.json(acme-own). Two launches both answer 201.settings.jsonstays a regular file with the same bytes, and the other links match after each launch (AC8).TestBug181SandboxClaudeDirNotADirRefused:Wis a symlink to<i.Home>/acme-elsewhere.POST /api/sessionsanswers 400 with codesandbox_unavailableand a message containingW, andacme-elsewherestays empty (AC9).
AC10: review only. The diff changes no read entry and no keychain path.
Test surface
- e2e (integration through HTTP and CLI): AC1 to AC9.
- Playwright: none, because no UI changes.
- Real-app check (test machine, needs no account): build the head, then start a sandboxed
claude-codeterminal. Before (main):claude auth statusinside reads the stale store. After (head):CLAUDE_CONFIG_DIRis the folder, the links are present, andclaude auth statusreportsloggedIn: false, the decided one-time logout. - Interactive, owner only, not a merge condition:
/logininside a sandboxed Claude, then a check past one token refresh.
Waits
- Task A: Waits: none; it adds no async work. Seeding finishes inside
AuthorizeLaunchbefore the launch response. - Task B: Waits: boot on
harness.Start(ready.json) andWaitSessionHost. A session launch waits on its 201, a turn on thesession messagereply (the start line is written before the first reply). A terminal waits on thesession.exitedevent for its id.relay sandboxwaits on the CLI process exit. A refusal waits on the HTTP response. Never a sleep.
Budget
- A: 8 files, about 160 lines (code about 110, docs about 50).
- B: 3 files, about 260 lines.
- Single PR, about 420 lines. Past 11 files or 550 lines is a question for the coordinator.
Tasks
- A: dev, as above.
- B: tests 1 to 9. Red on main: test 1 fails at its assertion on
main(CLAUDE_CONFIG_DIRunset) throughPOST /api/sessions. - Verification task (last; no devlock):
repo-suite(cd e2e && go test -race ./features/). Thendevboxverify:stack-up r181 --relay <head checkout> --eve <eve main checkout>,go run ./cmd/devboxverify --stack <stack.json> --phase api,stack-down r181. Then thereal-appcheck above. PostVerified: <head> · repo-suite: … · devboxverify: … · real-app: … · log: …. The login journeys are expected to fail until the owner's/login(Q2).
Open questions (recommendation first)
- INSIDE. Should the grant go in code? Yes, code, as with the pi leaf. The env value must be code anyway: env values do not expand
~, and a claude session reads no template env. The grant could only go in the template through a settings migration that rewrites every install'sclaude-codetemplate. That is a larger diff, it edits the person's config, and it lets the env and the grant disagree. - BEYOND. This PR's stack run, and every later verify on the machine, fails the five login journeys until the owner runs
/loginonce inside a sandboxed Claude on the test machine. Recommendation: the PR records them as an expected known defect pointing at this issue, with no harness change. Pause the nightly (its pause file) from merge until that/login, so a RED night from those journeys does not trigger Revert first. - INSIDE. Should
projectsandsessionsbe linked, beyond the owner's list? Yes. Without them, history on join, transcript delete and the outside-session probe lose sandboxed sessions. Neither holds credentials. - BEYOND. Should
pluginsbe linked? No, not in this PR; it is outside the owner's list. A sandboxed Claude loses the person's plugins until a follow-up is approved. File afrom-devidea if anyone misses them. - INSIDE. Should seeding replace a link that points elsewhere (one a session planted)? No: never overwrite, links included. Such a link only reaches what the profile already grants.
ship-blocker: no (owner decision 2026-10-10: not a regression; a known issue until the owner has a working sandbox login)
Activity
barelyworkingcode commentedon Oct 1, 2026
Update from reading Claude Code 2.1.286's credential storage. The root cause is now much firmer, and option A needs no flag.
Mechanism. Storage is "keychain with plaintext fallback". Read: keychain first, and the file only if the keychain has no item. Write: try the keychain. If that fails for a non-transient reason, write
~/.claude/.credentials.jsonand try to delete the keychain item.In the sandbox the keychain write is denied (
Operation not permitted), so the refreshed tokens land in the file. The keychain delete is denied too, so the old item stays. The next read takes the stale keychain item over the fresh file. That is the stale login. A login inside the sandbox fails the same way.Measured. With the
~/Library/Keychainsread grant removed from the profile,security find-generic-passwordinside the sandbox exits 44 ("item not found"). Claude Code reads that as an absent item and falls through to the file.Fix (option A). Drop
~/Library/Keychainsfrom the Claude template'sreadlist. That tightens the profile.~/.claudeis already read-write, so the file needs no new grant and no flag.Caveats.
sandbox.gosays the keychain "must stay readable for Claude Code to stay logged in". That holds only when the keychain is the sole store. The file must be populated first.DISABLE_AUTOUPDATERguidance above.barelyworkingcode commentedon Oct 1, 2026
Better fix than removing the keychain grant: there is an env var, not a flag.
CLAUDE_SECURESTORAGE_CONFIG_DIR=<dir>in sandbox sessions.In 2.1.286 it sets the credential file directory (
<dir>/.credentials.json) and suffixes the keychain service name with a hash of<dir>. The sandbox then looks up a keychain item that does not exist, so the staleClaude Code-credentialsitem can no longer shadow the file. Pointing it at~/.claudekeeps the file where it already is, and the profile needs no change. There is no flag that forces the file alone.Measured, with
claude auth status:loggedIn: true~/.claude, inside the sandbox profile:loggedIn: true(read from the file)loggedIn: false, so the variable does redirect storageNot yet measured: a real token refresh through this path, and whether the sandbox login and the unsandboxed login can refresh independently. Safest seeding is a separate
/logininside the sandbox, which gives it its own grant rather than a copy of the host's refresh token.barelyworkingcode commentedon Oct 10, 2026
Owner decision (2026-10-10, item 1, intent; the owner removed needs-review): approved. Fix goes in the bug bash epic, relay#339, as its first item. It touches credentials (item 3): the owner signed this one off, and the PR still goes through merge-check holds for the secrets path.
barelyworkingcode commentedon Oct 10, 2026
Decisions made for the owner
claudesession and a sandboxed terminal launch of theclaude-codetemplate (eve terminal card,relay sandbox claude-code). An operator's own template that runsclaudeunder another id can set the two keys in itsenv; a key the template sets wins.CLAUDE_SECURESTORAGE_CONFIG_DIRis<home>/.claudefromos.UserHomeDir, not symlink-resolved, so Claude's hashed keychain service name stays stable. A home-dir error refuses the launch.internal/sessions/childenv(existing leaf package) holds the two names for both call sites;e2e/fakes/fakeagent/claude.goexits after its start line on a tty so terminal tests wait onsession.exitedinstead of polling./logininside the sandbox if the file has no login (docs/session-host.md).Merge pull request #360 from barelyworkingcode/fix/181-sandbox-claude…
barelyworkingcode commentedon Oct 10, 2026
Reopened: PR #360 is being reverted (PR #366, bug #365) because the epic's full verify on
mainwent red. Not re-landed in this pass (owner's watcher, board #171).Findings
devboxverify --epic 339onmain9ae076f, Relay.app rebuilt from it: every journey running a local sandboxed Claude session failed:session-agent-state,session-drop-in,chief-of-staff-send,cos-startFAIL;session-drop-in-tool-refusedBLOCKED. Each reads as a turn that never reaches idle.cos-start-host(SSH host, not sandboxed) and every other journey passed.CLAUDE_SECURESTORAGE_CONFIG_DIR=~/.claude, a sandboxed Claude reads its login only from~/.claude/.credentials.json. On the test machine that file holds aclaudeAiOauthentry whoseexpiresAtis missing: a stale copy, not the live login, which sits in the keychain. Likely the sandboxed sessions ran with a dead login. Not confirmed (no session transcript inspected).What a re-land needs (proposal for the owner)
/logininside the sandbox (its own grant), or relay refusing to set the variable when the file has no live login. Which one changes what a user sees, so it is an owner decision.barelyworkingcode commentedon Oct 10, 2026
Owner decision (2026-10-10): stage one. Sandboxed Claude launches get the documented CLAUDE_CONFIG_DIR (one shared relay-owned dir, seeded with links to settings, skills, agents and commands, never credentials). The person runs /login once inside the sandbox. PR 360 relied on the undocumented CLAUDE_SECURESTORAGE_CONFIG_DIR and was reverted after the full verify. Journeys that need a logged-in sandboxed Claude report blocked until a manual login; the owner tests the login by hand. Issue stays open with ship-blocker. A larger isolation design (separate user, Apple containers) is being researched by the owner and is not part of this.
barelyworkingcode commentedon Oct 11, 2026
Taken out of epic relay#339 by the owner (2026-10-10); tracked on its own as a ship-blocker. Stage one is in progress on branch
fix/181-claude-config-dir.barelyworkingcode commentedon Oct 11, 2026
Owner decision (2026-10-10): options 4 and 2. DISABLE_AUTOUPDATER=1 and the documented CLAUDE_CONFIG_DIR go in as configuration (the claude-code template's env and read_write in settings.json) wherever the config path can carry them; code only for what it cannot. The five journeys that need a login report not-run, not blocked. The PR may merge without the owner's login. This issue stays open with ship-blocker until the owner tests a sandbox login by hand.
barelyworkingcode commentedon Oct 11, 2026
Owner decision (2026-10-10): no work on this issue for now. The owner is testing the sandbox login by hand on his Mac and will return with a working solution; then this bug is picked up. Stage one (documented CLAUDE_CONFIG_DIR, DISABLE_AUTOUPDATER, journeys not-run without a login) is cancelled and nothing is built. PR 360 stays reverted. The issue stays open and labelled ship-blocker, so relay does not read as shippable until it is resolved. Findings so far: CLAUDE_SECURESTORAGE_CONFIG_DIR is undocumented; CLAUDE_CONFIG_DIR is the documented per-process lever; the sandboxed Claude cannot refresh a login it cannot write; a same-user process can read another process's environment (tested), so a token in env is not private.
barelyworkingcode commentedon Oct 11, 2026
Stage one is cancelled (owner, 2026-10-10). No branch or PR exists. The owner is testing the sandbox by hand and will return to this bug with a working solution. It stays open with ship-blocker and gets no work until then.
barelyworkingcode commentedon Oct 11, 2026
Owner decision (2026-10-10): not a ship-blocker for this ship. The bug is not a regression: the sandbox works today with a login inside it, and the owner tests that by hand. The issue stays open as a known issue and is picked up when the owner has a working solution. Label ship-blocker removed on the owner's instruction.
barelyworkingcode commentedon Oct 15, 2026
Groomed (coordinator, 2026-10-14): Renamed
## Root cause (partly measured)and## Fix options (needs owner decision: secrets)to## Root causeand## Fix; each qualifier moved to the first line of its section. No change of meaning.barelyworkingcode commentedon Oct 15, 2026
Groomed (coordinator, 2026-10-14): added
ship-blocker: noagainst the ship-blocker criteria in the flow rules (a security hole inside the threat model that real use can reach; loss of a user's work, data or login; a defect in a must-have feature group). This restates the owner's recorded decision of 2026-10-10; it is not a new one. The owner can flip it. No other change.barelyworkingcode commentedon Oct 19, 2026
Parked (coordinator, 2026-10-18): the login half. The auto-update half moved to relay#500 and ships on its own.
Confirmed on main 9ff41de
claude-codesession, a keychain write getsOperation not permitted; a keychain read works;~/.claudeis writable.~/.claude/.credentials.jsonholds aclaudeAiOauthentry with empty tokens, written today. The live login is in the keychain. This matches the mechanism above: the sandbox writes the file, and reads the stale keychain.CLAUDE_CONFIG_DIR=<empty dir> claude auth statusreportsloggedIn: falseon Claude Code 2.1.303. The documented lever does give a separate store.Why it is parked
Every storage switch makes existing sandboxed Claude sessions log out until someone runs
/logininside the sandbox. PR 360 did this and was reverted after the full verify. Proving any fix needs that/loginwith your account and an 8-hour refresh. A run cannot do either.What would unblock it
CLAUDE_CONFIG_DIR(documented; settings links seeded) orCLAUDE_SECURESTORAGE_CONFIG_DIR=~/.claude(undocumented)./loginonce inside a sandboxed Claude on the test machine, so the five login journeys have a live login.claude auth status.barelyworkingcode commentedon Oct 19, 2026
The auto-update half shipped in relay#502 (closes relay#500). The login half stays open and parked; see the comment above.
Merge pull request #503 from barelyworkingcode/fix/181-login-store