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 <claude@anthropic.com>

* 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 <claude@anthropic.com>

* docs(readme): credit @pb3ck for #482 diagnosis

Co-Authored-By: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
Travis
2026-04-21 16:33:59 -05:00
committed by GitHub
parent 35d4735b2d
commit a9719c93cc
3 changed files with 104 additions and 11 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
# =============================================================================