From a9719c93ccc7d1f1b374122c5a97b0625e586e8a Mon Sep 17 00:00:00 2001 From: Travis Date: Tue, 21 Apr 2026 16:33:59 -0500 Subject: [PATCH] fix(cowork): forward CLAUDE_CODE_OAUTH_TOKEN to VM spawn env (#482) (#485) * fix(cowork): forward CLAUDE_CODE_OAUTH_TOKEN to VM spawn env (#482) buildSpawnEnv and BwrapBackend.spawn both stripped every CLAUDE_CODE_* env var from the daemon's process.env via filterEnv(process.env, ['CLAUDE_CODE_']) -- including CLAUDE_CODE_OAUTH_TOKEN, the standard auth channel for the in-VM claude binary. The bwrap sandbox mounts home as an empty --dir, so ~/.claude/.credentials.json is inaccessible inside; env is the only viable auth path. Result: every shell tool call returned "Not logged in. Please run /login". Upstream's seA() does include the token in the spawn env it assembles, but on Linux that payload isn't reaching the daemon's params.env, so the daemon's inherited process.env is the only surviving source. Stripping it severed auth. Introduce FORWARDED_ENV_KEYS = ['CLAUDE_CODE_OAUTH_TOKEN'] plus a forwardAuthEnv helper that re-adds the token from process.env when appEnv doesn't provide one. Extract buildBaseSpawnEnv as the shared env-construction path for both spawn sites so the filter/forward logic can't drift. Diagnosis and reference diff by @pb3ck in #482. This PR extends the fix to BwrapBackend.spawn (the second call site), factors the shared helper, and adds regression coverage. Co-Authored-By: Claude * refactor(cowork): fold forwardAuthEnv into buildBaseSpawnEnv The forwardAuthEnv helper had a single call site inside buildBaseSpawnEnv. Inlining the forward loop removes a layer of indirection for a private helper and consolidates the empty-string guard comment next to the code it documents. No behavior change. All 72 tests in cowork-path-translation.bats still pass, including the four #482 regression tests. Co-Authored-By: Claude * docs(readme): credit @pb3ck for #482 diagnosis Co-Authored-By: Claude --------- Co-authored-by: Claude --- README.md | 1 + scripts/cowork-vm-service.js | 51 +++++++++++++++++++----- tests/cowork-path-translation.bats | 63 +++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2a26b42..25b40e3 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ Special thanks to: - **[martin152](https://github.com/martin152)** for detailed diagnosis and a complete patch for three launcher cleanup bugs: `cleanup_orphaned_cowork_daemon` self-match, `cleanup_stale_cowork_socket` socat dependency no-op, and the same self-match in `--doctor` - **[hfyeh](https://github.com/hfyeh)** for diagnosing the Ubuntu 24.04 AppArmor unprivileged-userns block on Cowork bwrap and contributing the AppArmor profile workaround - **[davidamacey](https://github.com/davidamacey)** for identifying and fixing the XRDP GPU compositing blank-window issue on remote desktop sessions +- **[pb3ck](https://github.com/pb3ck)** for diagnosing the Cowork `CLAUDE_CODE_OAUTH_TOKEN` env-strip bug with a working reference diff ## Sponsorship diff --git a/scripts/cowork-vm-service.js b/scripts/cowork-vm-service.js index 8025746..b04b62e 100644 --- a/scripts/cowork-vm-service.js +++ b/scripts/cowork-vm-service.js @@ -138,6 +138,21 @@ const BLOCKED_ENV_KEYS = new Set([ 'CLAUDECODE', 'ELECTRON_RUN_AS_NODE', 'ELECTRON_NO_ASAR', ]); +/** + * CLAUDE_CODE_* keys forwarded from process.env despite the prefix + * strip in filterEnv. Upstream's seA() intends these to arrive via + * the spawn RPC's params.env, but on Linux the daemon's inherited + * process.env is the only surviving source, so we re-add it as a + * fallback. appEnv values take precedence when present. + * + * Caveat: process.env is snapshotted at daemon launch, so a token + * refresh in ~/.claude/.credentials.json won't be picked up until + * the daemon restarts. + * + * See https://github.com/aaddrick/claude-desktop-debian/issues/482. + */ +const FORWARDED_ENV_KEYS = ['CLAUDE_CODE_OAUTH_TOKEN']; + /** * Filter environment variables, removing blocked keys and optional prefixes. */ @@ -151,6 +166,30 @@ function filterEnv(source, stripPrefixes = []) { return result; } +/** + * Shared base env for HostBackend (buildSpawnEnv) and + * BwrapBackend.spawn: strips the CLAUDE_CODE_ prefix from + * process.env, overlays appEnv, forces TERM, then re-adds + * FORWARDED_ENV_KEYS as a fallback. + * + * The `=== undefined` check (not truthiness) lets an explicit + * empty-string in appEnv (e.g. Foundry mode signalling "no + * token") win over the daemon's inherited value. + */ +function buildBaseSpawnEnv(appEnv) { + const mergedEnv = { + ...filterEnv(process.env, ['CLAUDE_CODE_']), + ...filterEnv(appEnv || {}), + TERM: 'xterm-256color', + }; + for (const key of FORWARDED_ENV_KEYS) { + if (process.env[key] && mergedEnv[key] === undefined) { + mergedEnv[key] = process.env[key]; + } + } + return mergedEnv; +} + // ============================================================ // Guest-Path Translation // ============================================================ @@ -267,11 +306,7 @@ function findPrimaryMount(mountMap) { * CLAUDE_CONFIG_DIR and CLAUDE_COWORK_MEMORY_PATH_OVERRIDE using mountMap. */ function buildSpawnEnv(appEnv, mountMap) { - const mergedEnv = { - ...filterEnv(process.env, ['CLAUDE_CODE_']), - ...filterEnv(appEnv || {}), - TERM: 'xterm-256color', - }; + const mergedEnv = buildBaseSpawnEnv(appEnv); // Translate CLAUDE_CONFIG_DIR from guest path to host path, or // remove it so Claude Code falls back to ~/.claude/. @@ -1227,11 +1262,7 @@ class BwrapBackend extends LocalBackend { // Guest paths (/sessions/...) exist inside our bwrap sandbox, // so pass args and env through as-is (no guest->host translation). const rawArgs = params.args || []; - const mergedEnv = { - ...filterEnv(process.env, ['CLAUDE_CODE_']), - ...filterEnv(params.env || {}), - TERM: 'xterm-256color', - }; + const mergedEnv = buildBaseSpawnEnv(params.env); // Build a minimal sandbox: empty tmpfs root with only the // necessary system paths bound in read-only. This avoids diff --git a/tests/cowork-path-translation.bats b/tests/cowork-path-translation.bats index 11d1ec5..bf27169 100644 --- a/tests/cowork-path-translation.bats +++ b/tests/cowork-path-translation.bats @@ -210,6 +210,8 @@ const BLOCKED_ENV_KEYS = new Set([ "CLAUDECODE", "ELECTRON_RUN_AS_NODE", "ELECTRON_NO_ASAR", ]); +const FORWARDED_ENV_KEYS = ["CLAUDE_CODE_OAUTH_TOKEN"]; + function filterEnv(source, stripPrefixes = []) { const result = {}; for (const [k, v] of Object.entries(source)) { @@ -220,12 +222,22 @@ function filterEnv(source, stripPrefixes = []) { return result; } -function buildSpawnEnv(appEnv, mountMap) { +function buildBaseSpawnEnv(appEnv) { const mergedEnv = { ...filterEnv(process.env, ["CLAUDE_CODE_"]), ...filterEnv(appEnv || {}), TERM: "xterm-256color", }; + for (const key of FORWARDED_ENV_KEYS) { + if (process.env[key] && mergedEnv[key] === undefined) { + mergedEnv[key] = process.env[key]; + } + } + return mergedEnv; +} + +function buildSpawnEnv(appEnv, mountMap) { + const mergedEnv = buildBaseSpawnEnv(appEnv); if (mergedEnv.CLAUDE_CONFIG_DIR && mergedEnv.CLAUDE_CONFIG_DIR.startsWith("/sessions/")) { const translated = translateGuestPath( @@ -1118,6 +1130,55 @@ assertEqual(env.TERM, 'xterm-256color', 'TERM forced'); [[ "$status" -eq 0 ]] } +# Regression coverage for issue #482: the CLAUDE_CODE_ prefix strip used to +# remove CLAUDE_CODE_OAUTH_TOKEN from process.env, severing the only auth +# channel available to the in-VM claude binary. + +@test "buildSpawnEnv: forwards CLAUDE_CODE_OAUTH_TOKEN from process.env" { + CLAUDE_CODE_OAUTH_TOKEN='tok-from-daemon' run node -e "${NODE_PREAMBLE} +const env = buildSpawnEnv({}, {}); +assertEqual(env.CLAUDE_CODE_OAUTH_TOKEN, + 'tok-from-daemon', + 'OAUTH_TOKEN forwarded from process.env'); +" + [[ "$status" -eq 0 ]] +} + +@test "buildSpawnEnv: appEnv CLAUDE_CODE_OAUTH_TOKEN wins over process.env" { + CLAUDE_CODE_OAUTH_TOKEN='tok-from-daemon' run node -e "${NODE_PREAMBLE} +const env = buildSpawnEnv( + { CLAUDE_CODE_OAUTH_TOKEN: 'tok-from-app' }, + {} +); +assertEqual(env.CLAUDE_CODE_OAUTH_TOKEN, + 'tok-from-app', + 'appEnv token takes precedence'); +" + [[ "$status" -eq 0 ]] +} + +@test "buildSpawnEnv: explicit empty appEnv token wins over process.env" { + CLAUDE_CODE_OAUTH_TOKEN='tok-from-daemon' run node -e "${NODE_PREAMBLE} +const env = buildSpawnEnv( + { CLAUDE_CODE_OAUTH_TOKEN: '' }, + {} +); +assertEqual(env.CLAUDE_CODE_OAUTH_TOKEN, + '', + 'explicit empty-string preserved'); +" + [[ "$status" -eq 0 ]] +} + +@test "buildSpawnEnv: still strips unrelated CLAUDE_CODE_* from process.env" { + CLAUDE_CODE_SSE_PORT='9999' run node -e "${NODE_PREAMBLE} +const env = buildSpawnEnv({}, {}); +assert(!('CLAUDE_CODE_SSE_PORT' in env), + 'non-allowlisted CLAUDE_CODE_ var still stripped'); +" + [[ "$status" -eq 0 ]] +} + # ============================================================================= # cleanSpawnArgs edge cases # =============================================================================