From 161b85b95899fcfe5d5ca1cbc78eff0e8dd23a82 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Thu, 17 Sep 2026 13:00:53 -0700 Subject: [PATCH 1/3] =?UTF-8?q?claude:=20=F0=9F=94=92=EF=B8=8F=20Pass=20is?= =?UTF-8?q?olated=20OAuth=20tokens=20through=20the=20child=20environment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: Isolated Claude runs copied the full credential JSON into a temporary config directory. Signal-killed parents bypassed cleanup, and Claude Code migrated the seeded file into a persistent Keychain item named after the temporary directory. Summary: Extract the access token in the shared core and return it as CLAUDE_CODE_OAUTH_TOKEN in the seed env map. Pass that map to the child in Python, Go, and Rust, seeding only the account pointer on disk. Motivation: Prevent credentials from surviving isolated runs in leaked temporary directories or accumulating as Keychain copies. Authentication must not depend on exit handlers removing a secret file. Details: Preserve mcpServers stripping, update 5 conformance vectors and host isolation tests, and add a Python SIGKILL regression. Record the security fix and restore the 0.13.3 changelog heading and compare links. Expired tokens fail with an explicit 401; existing leaked credentials remain for operator cleanup. Claude-Session-Id: 1dd4ee33-1b87-4c1f-af93-7a8827b07ef5 --- CHANGELOG.md | 24 +++++- .../claude_isolation_seed/account-only.json | 3 +- .../account-without-mcp-servers.json | 3 +- .../both-files-mcp-popped.json | 10 +-- .../claude_isolation_seed/both-null.json | 3 +- .../credentials-only.json | 11 +-- go/coreops.go | 3 +- go/exec.go | 4 +- go/exec_test.go | 20 +++-- go/isolate.go | 16 ++-- go/testdata/bin/claude | 5 +- rust/spawnllm-core/src/isolate.rs | 26 +++++-- rust/spawnllm/src/exec.rs | 13 +++- rust/spawnllm/src/isolate.rs | 11 ++- rust/spawnllm/src/run.rs | 16 ++-- rust/spawnllm/tests/common/mod.rs | 6 +- rust/spawnllm/tests/isolation.rs | 18 ++--- spawnllm/backends/base.py | 35 +++++++-- spawnllm/backends/claude.py | 26 ++++--- tests/test_backends.py | 75 +++++++++++++------ 20 files changed, 210 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eff2d0..6da4104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security +- **Isolated `claude` runs no longer write the claude.ai token to disk.** The + isolation seed copied the whole Keychain credential into a + `.credentials.json` under the per-process `CLAUDE_CONFIG_DIR`, and every + host removed that dir only at process exit, so a parent killed by a signal + (a hook host at Claude Code's hook timeout, an `os._exit`) left the token + copy behind; one machine held 77 such dirs. Claude Code also migrated each + seeded file into a Keychain item named after the throwaway dir, and those + outlived the run too. The core's `claude_isolation_seed` now hands the + access token back as an `env` map, `CLAUDE_CODE_OAUTH_TOKEN`, that every + host sets on the child, and seeds only the account pointer; nothing secret + touches the filesystem, a leaked dir holds no credential, and the child + stores nothing in the Keychain. Claude Code authenticates from that + variable without refreshing it, so a run started after the Keychain's + access token expired fails with its `401 OAuth access token is invalid` + instead of refreshing; any Claude Code session on the machine keeps the + Keychain token current. + +## [0.13.3] - 2026-09-17 + ### Fixed - **Isolated `claude` runs from the default config home find the Keychain token again.** With `CLAUDE_CONFIG_DIR` unset, Claude Code stores the @@ -447,7 +467,9 @@ First release, published to PyPI as `spawnllm`. generation. - Click CLI: `spawnllm backends` and `spawnllm call`. -[Unreleased]: https://github.com/yasyf/spawnllm/compare/v0.13.1...HEAD +[Unreleased]: https://github.com/yasyf/spawnllm/compare/v0.13.3...HEAD +[0.13.3]: https://github.com/yasyf/spawnllm/compare/v0.13.2...v0.13.3 +[0.13.2]: https://github.com/yasyf/spawnllm/compare/v0.13.1...v0.13.2 [0.13.1]: https://github.com/yasyf/spawnllm/compare/v0.13.0...v0.13.1 [0.13.0]: https://github.com/yasyf/spawnllm/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/yasyf/spawnllm/compare/v0.11.0...v0.12.0 diff --git a/conformance/vectors/claude_isolation_seed/account-only.json b/conformance/vectors/claude_isolation_seed/account-only.json index 68f3cc9..de5b8be 100644 --- a/conformance/vectors/claude_isolation_seed/account-only.json +++ b/conformance/vectors/claude_isolation_seed/account-only.json @@ -12,6 +12,7 @@ "content": "{\"oauthAccount\": {\"accountUuid\": \"b\"}}", "mode": "0644" } - ] + ], + "env": {} } } diff --git a/conformance/vectors/claude_isolation_seed/account-without-mcp-servers.json b/conformance/vectors/claude_isolation_seed/account-without-mcp-servers.json index 6cb1d47..4c5a841 100644 --- a/conformance/vectors/claude_isolation_seed/account-without-mcp-servers.json +++ b/conformance/vectors/claude_isolation_seed/account-without-mcp-servers.json @@ -12,6 +12,7 @@ "content": "{\"oauthAccount\": {\"accountUuid\": \"c\"}}", "mode": "0644" } - ] + ], + "env": {} } } diff --git a/conformance/vectors/claude_isolation_seed/both-files-mcp-popped.json b/conformance/vectors/claude_isolation_seed/both-files-mcp-popped.json index 68e1fa5..5a486a8 100644 --- a/conformance/vectors/claude_isolation_seed/both-files-mcp-popped.json +++ b/conformance/vectors/claude_isolation_seed/both-files-mcp-popped.json @@ -11,12 +11,10 @@ "name": ".claude.json", "content": "{\"oauthAccount\": {\"accountUuid\": \"a\"}}", "mode": "0644" - }, - { - "name": ".credentials.json", - "content": "{\"claudeAiOauth\": {\"accessToken\": \"tok\"}}", - "mode": "0600" } - ] + ], + "env": { + "CLAUDE_CODE_OAUTH_TOKEN": "tok" + } } } diff --git a/conformance/vectors/claude_isolation_seed/both-null.json b/conformance/vectors/claude_isolation_seed/both-null.json index 6834a3d..ef4ee7b 100644 --- a/conformance/vectors/claude_isolation_seed/both-null.json +++ b/conformance/vectors/claude_isolation_seed/both-null.json @@ -6,6 +6,7 @@ "credentials_json": null }, "expected": { - "files": [] + "files": [], + "env": {} } } diff --git a/conformance/vectors/claude_isolation_seed/credentials-only.json b/conformance/vectors/claude_isolation_seed/credentials-only.json index 2eed95b..2261a11 100644 --- a/conformance/vectors/claude_isolation_seed/credentials-only.json +++ b/conformance/vectors/claude_isolation_seed/credentials-only.json @@ -6,12 +6,9 @@ "credentials_json": "{\"claudeAiOauth\": {\"accessToken\": \"kc-tok\"}}" }, "expected": { - "files": [ - { - "name": ".credentials.json", - "content": "{\"claudeAiOauth\": {\"accessToken\": \"kc-tok\"}}", - "mode": "0600" - } - ] + "files": [], + "env": { + "CLAUDE_CODE_OAUTH_TOKEN": "kc-tok" + } } } diff --git a/go/coreops.go b/go/coreops.go index 6a1688a..7baf15e 100644 --- a/go/coreops.go +++ b/go/coreops.go @@ -106,7 +106,8 @@ type seedFile struct { } type isolationSeed struct { - Files []seedFile `json:"files"` + Files []seedFile `json:"files"` + Env map[string]string `json:"env"` } func coreCall(op string, input any) (json.RawMessage, error) { diff --git a/go/exec.go b/go/exec.go index d663160..241013d 100644 --- a/go/exec.go +++ b/go/exec.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "maps" "os" "os/exec" "runtime" @@ -51,12 +52,13 @@ func runExecPlan(ctx context.Context, plan execPlan, spec RunSpec) (output strin env := plan.Env if plan.NeedsClaudeIsolation { - dir, cleanup, e := seedClaudeIsolation() + dir, seedEnv, cleanup, e := seedClaudeIsolation() if e != nil { return "", 0, "", false, e } cleanups = append(cleanups, cleanup) env = substituteIsolationDir(plan.Env, dir) + maps.Copy(env, seedEnv) } argv := substituteFiles(plan.Argv, paths) diff --git a/go/exec_test.go b/go/exec_test.go index fa31d04..38f3900 100644 --- a/go/exec_test.go +++ b/go/exec_test.go @@ -26,6 +26,7 @@ func withFakeBin(t *testing.T) { type claudeOutput struct { StdoutRegular bool `json:"stdout_regular"` ConfigDir string `json:"config_dir"` + OauthToken string `json:"oauth_token"` Seeded bool `json:"seeded"` AccountHasMCP bool `json:"account_has_mcp"` CredsPresent bool `json:"creds_present"` @@ -314,11 +315,14 @@ func TestClaudeIsolationSeeding(t *testing.T) { if out.AccountHasMCP { t.Fatal("seeded .claude.json still carried mcpServers") } - if !out.CredsPresent { - t.Fatal("isolated config dir was not seeded with .credentials.json") + if out.CredsPresent { + t.Fatal("isolated config dir carried a .credentials.json") + } + if out.OauthToken != "tok" { + t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN = %q, want the credentials file's access token", out.OauthToken) } - if out.ConfigDirMode != "drwx------" || out.CredsMode != "-rw-------" { - t.Fatalf("isolated config dir mode %q, credentials mode %q; want drwx------ and -rw-------", out.ConfigDirMode, out.CredsMode) + if out.ConfigDirMode != "drwx------" { + t.Fatalf("isolated config dir mode %q, want drwx------", out.ConfigDirMode) } if _, err := os.Stat(out.ConfigDir); !os.IsNotExist(err) { t.Fatalf("isolated config dir was not cleaned up: stat err = %v", err) @@ -444,8 +448,8 @@ func TestClaudeIsolationKeychain(t *testing.T) { if want := "find-generic-password\n-s\n" + service + "\n-w\n"; argv != want { t.Fatalf("security argv = %q, want %q", argv, want) } - if !out.CredsPresent || out.CredsMode != "-rw-------" { - t.Fatalf("keychain credentials seeded = %v with mode %q; want seeded at -rw-------", out.CredsPresent, out.CredsMode) + if out.CredsPresent || out.OauthToken != "kc-tok" { + t.Fatalf("credentials file present = %v, CLAUDE_CODE_OAUTH_TOKEN = %q; want the Keychain token in env only", out.CredsPresent, out.OauthToken) } }) } @@ -461,8 +465,8 @@ func TestClaudeIsolationKeychainMissSeedsNoCredentials(t *testing.T) { if want := "find-generic-password\n-s\n" + suffixedKeychainService(acct) + "\n-w\n"; argv != want { t.Fatalf("security argv = %q, want %q", argv, want) } - if out.CredsPresent { - t.Fatal("a Keychain miss must seed no credentials file") + if out.CredsPresent || out.OauthToken != "" { + t.Fatalf("credentials file present = %v, CLAUDE_CODE_OAUTH_TOKEN = %q; a Keychain miss must seed no token", out.CredsPresent, out.OauthToken) } } diff --git a/go/isolate.go b/go/isolate.go index 598136c..f3388ee 100644 --- a/go/isolate.go +++ b/go/isolate.go @@ -9,10 +9,10 @@ import ( "strings" ) -func seedClaudeIsolation() (string, func(), error) { +func seedClaudeIsolation() (string, map[string]string, func(), error) { sources, err := coreIsolationSources() if err != nil { - return "", nil, err + return "", nil, nil, err } accountJSON := readFileOpt(sources.AccountPath) credentialsJSON := readFileOpt(sources.CredentialsPath) @@ -22,31 +22,31 @@ func seedClaudeIsolation() (string, func(), error) { seed, err := coreIsolationSeed(accountJSON, credentialsJSON) if err != nil { - return "", nil, err + return "", nil, nil, err } dir, err := os.MkdirTemp("", "spawnllm-claude-config-") if err != nil { - return "", nil, err + return "", nil, nil, err } cleanup := func() { _ = os.RemoveAll(dir) } for _, f := range seed.Files { mode, err := parseMode(f.Mode) if err != nil { cleanup() - return "", nil, err + return "", nil, nil, err } path := filepath.Join(dir, f.Name) if err := os.WriteFile(path, []byte(f.Content), mode); err != nil { cleanup() - return "", nil, err + return "", nil, nil, err } if err := os.Chmod(path, mode); err != nil { cleanup() - return "", nil, err + return "", nil, nil, err } } - return dir, cleanup, nil + return dir, seed.Env, cleanup, nil } func substituteIsolationDir(env map[string]string, dir string) map[string]string { diff --git a/go/testdata/bin/claude b/go/testdata/bin/claude index 968882d..a09315b 100755 --- a/go/testdata/bin/claude +++ b/go/testdata/bin/claude @@ -15,6 +15,7 @@ else fi config_dir="${CLAUDE_CONFIG_DIR:-}" +oauth_token="${CLAUDE_CODE_OAUTH_TOKEN:-}" seeded=false account_has_mcp=false creds_present=false @@ -50,5 +51,5 @@ if [ -n "$FAKE_TRANSIENT_COUNTER" ]; then fi fi -printf '{"type":"result","is_error":false,"result":"%s","stdout_regular":%s,"config_dir":"%s","seeded":%s,"account_has_mcp":%s,"creds_present":%s,"config_dir_mode":"%s","creds_mode":"%s"}\n' \ - "$prompt" "$stdout_regular" "$config_dir" "$seeded" "$account_has_mcp" "$creds_present" "$config_dir_mode" "$creds_mode" +printf '{"type":"result","is_error":false,"result":"%s","stdout_regular":%s,"config_dir":"%s","oauth_token":"%s","seeded":%s,"account_has_mcp":%s,"creds_present":%s,"config_dir_mode":"%s","creds_mode":"%s"}\n' \ + "$prompt" "$stdout_regular" "$config_dir" "$oauth_token" "$seeded" "$account_has_mcp" "$creds_present" "$config_dir_mode" "$creds_mode" diff --git a/rust/spawnllm-core/src/isolate.rs b/rust/spawnllm-core/src/isolate.rs index d6fe7bb..6f534c6 100644 --- a/rust/spawnllm-core/src/isolate.rs +++ b/rust/spawnllm-core/src/isolate.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::io; use serde::{Deserialize, Serialize}; @@ -37,9 +38,22 @@ struct IsolationSeedInput { credentials_json: Option, } +#[derive(Debug, Deserialize)] +struct Credentials { + #[serde(rename = "claudeAiOauth")] + claude_ai_oauth: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeAiOauth { + #[serde(rename = "accessToken")] + access_token: String, +} + #[derive(Debug, Serialize)] struct IsolationSeed { files: Vec, + env: BTreeMap<&'static str, String>, } #[derive(Debug, Serialize)] @@ -153,14 +167,14 @@ fn isolation_seed(input: IsolationSeedInput) -> Result(&credentials_json)?; + if let Some(oauth) = credentials.claude_ai_oauth { + env.insert("CLAUDE_CODE_OAUTH_TOKEN", oauth.access_token); + } } - Ok(IsolationSeed { files }) + Ok(IsolationSeed { files, env }) } pub(crate) fn dispatch(op: &str, input: Value) -> OpResult { diff --git a/rust/spawnllm/src/exec.rs b/rust/spawnllm/src/exec.rs index ed58e2e..f7d4f52 100644 --- a/rust/spawnllm/src/exec.rs +++ b/rust/spawnllm/src/exec.rs @@ -10,6 +10,7 @@ use tokio::process::{Child, Command}; use spawnllm_core::wire::{ExecPlan, FileId, ReadResultFrom}; use crate::backend::resolve_binary; +use crate::isolate::Isolation; use crate::run::{Attempt, AttemptKind, resolve_kind}; use crate::spec::RunSpec; @@ -17,7 +18,7 @@ pub(crate) async fn exec_attempt( plan: &ExecPlan, spec: &RunSpec, provider: &str, - isolated_dir: Option<&Path>, + isolation: Option<&Isolation>, wants_value: bool, ) -> std::io::Result { let mut temp_files: Vec = Vec::new(); @@ -66,12 +67,18 @@ pub(crate) async fn exec_attempt( cmd.env_remove(key); } for (key, value) in &plan.env { - let value = match isolated_dir { - Some(dir) => value.replace("${isolated_config_dir}", &dir.to_string_lossy()), + let value = match isolation { + Some(isolation) => value.replace( + "${isolated_config_dir}", + &isolation.dir.path().to_string_lossy(), + ), None => value.clone(), }; cmd.env(key, value); } + if let Some(isolation) = isolation { + cmd.envs(&isolation.env); + } if let Some(env) = &spec.env { for (key, value) in env { cmd.env(key, value); diff --git a/rust/spawnllm/src/isolate.rs b/rust/spawnllm/src/isolate.rs index f7bb791..53d6d41 100644 --- a/rust/spawnllm/src/isolate.rs +++ b/rust/spawnllm/src/isolate.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::Path; @@ -24,6 +25,7 @@ struct Sources { #[derive(Debug, Deserialize)] struct Seed { files: Vec, + env: BTreeMap, } #[derive(Debug, Deserialize)] @@ -33,7 +35,12 @@ struct SeedFile { mode: String, } -pub(crate) async fn seed_isolation() -> Result { +pub(crate) struct Isolation { + pub(crate) dir: TempDir, + pub(crate) env: BTreeMap, +} + +pub(crate) async fn seed_isolation() -> Result { let sources: Sources = core_op( "claude_isolation_sources", json!({ "host": { @@ -65,7 +72,7 @@ pub(crate) async fn seed_isolation() -> Result { handle.write_all(file.content.as_bytes())?; handle.flush()?; } - Ok(dir) + Ok(Isolation { dir, env: seed.env }) } fn private_tempdir() -> std::io::Result { diff --git a/rust/spawnllm/src/run.rs b/rust/spawnllm/src/run.rs index 16d571c..b67bc4b 100644 --- a/rust/spawnllm/src/run.rs +++ b/rust/spawnllm/src/run.rs @@ -1,7 +1,6 @@ use std::time::Duration; use serde_json::{Map, Value, json}; -use tempfile::TempDir; use spawnllm_core::wire::{ExecPlan, InvocationPlan, Resolved}; use spawnllm_core::{RetryInput, retry_decision}; @@ -151,9 +150,9 @@ async fn exec_loop( provider: &'static str, wants_value: bool, ) -> Response { - let isolated_dir = if plan.needs_claude_isolation { + let isolation = if plan.needs_claude_isolation { match crate::isolate::seed_isolation().await { - Ok(dir) => Some(dir), + Ok(isolation) => Some(isolation), Err(error) => return error_response(spec, error, Vec::new()), } } else { @@ -163,14 +162,9 @@ async fn exec_loop( let mut discarded = Vec::new(); let max = spec.max_attempts.max(1); for attempt in 0..max { - let outcome = crate::exec::exec_attempt( - &plan, - &spec, - provider, - isolated_dir.as_ref().map(TempDir::path), - wants_value, - ) - .await; + let outcome = + crate::exec::exec_attempt(&plan, &spec, provider, isolation.as_ref(), wants_value) + .await; let att = match outcome { Ok(att) => att, Err(error) => return error_response(spec, error.into(), discarded), diff --git a/rust/spawnllm/tests/common/mod.rs b/rust/spawnllm/tests/common/mod.rs index 7128964..8095e23 100644 --- a/rust/spawnllm/tests/common/mod.rs +++ b/rust/spawnllm/tests/common/mod.rs @@ -40,9 +40,9 @@ case "$stdin" in DUMP_SCHEMA_TO=*) printf '%s' "$schema" > "${stdin#DUMP_SCHEMA_ if [ -n "$SPAWNLLM_FAKE_MARKER" ]; then if [ -f /dev/stdout ]; then printf 'regular' > "$SPAWNLLM_FAKE_MARKER"; else printf 'pipe' > "$SPAWNLLM_FAKE_MARKER"; fi fi -if [ -n "$SPAWNLLM_FAKE_CRED_OUT" ]; then cat "$CLAUDE_CONFIG_DIR/.credentials.json" > "$SPAWNLLM_FAKE_CRED_OUT" 2>/dev/null || true; fi +if [ -n "$SPAWNLLM_FAKE_CRED_OUT" ]; then printf '%s' "${CLAUDE_CODE_OAUTH_TOKEN-}" > "$SPAWNLLM_FAKE_CRED_OUT"; fi if [ -n "$SPAWNLLM_FAKE_ACCOUNT_OUT" ]; then cat "$CLAUDE_CONFIG_DIR/.claude.json" > "$SPAWNLLM_FAKE_ACCOUNT_OUT" 2>/dev/null || true; fi -if [ -n "$SPAWNLLM_FAKE_MODES_OUT" ]; then { ls -ld "$CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR/.credentials.json" | cut -c1-10; } > "$SPAWNLLM_FAKE_MODES_OUT"; fi +if [ -n "$SPAWNLLM_FAKE_MODES_OUT" ]; then { ls -ld "$CLAUDE_CONFIG_DIR" | cut -c1-10; ls -A "$CLAUDE_CONFIG_DIR"; } > "$SPAWNLLM_FAKE_MODES_OUT"; fi if [ -n "$SPAWNLLM_FAKE_EXIT" ]; then printf 'boom' >&2; exit "$SPAWNLLM_FAKE_EXIT"; fi if [ -n "$SPAWNLLM_FAKE_SLEEP" ]; then sleep "$SPAWNLLM_FAKE_SLEEP"; fi if [ -n "$SPAWNLLM_FAKE_COUNTER" ]; then @@ -84,7 +84,7 @@ fi const SECURITY_FAKE: &str = r#"#!/bin/sh if [ -n "$SPAWNLLM_FAKE_SECURITY_ARGV_OUT" ]; then printf '%s\n' "$@" > "$SPAWNLLM_FAKE_SECURITY_ARGV_OUT"; fi if [ "$1" = "find-generic-password" ] && [ "$2" = "-s" ] && [ "$3" = "$SPAWNLLM_FAKE_KEYCHAIN_SERVICE" ] && [ "$4" = "-w" ] && [ $# -eq 4 ]; then - printf 'keychain-token-xyz'; exit 0 + printf '{"claudeAiOauth": {"accessToken": "keychain-token-xyz"}}'; exit 0 fi exit 44 "#; diff --git a/rust/spawnllm/tests/isolation.rs b/rust/spawnllm/tests/isolation.rs index faa5c91..8117b80 100644 --- a/rust/spawnllm/tests/isolation.rs +++ b/rust/spawnllm/tests/isolation.rs @@ -41,7 +41,7 @@ async fn isolation_seeds_stripped_account_and_credentials_from_files() { .unwrap(); std::fs::write( source.path().join(".credentials.json"), - r#"{"token": "abc"}"#, + r#"{"claudeAiOauth": {"accessToken": "abc"}}"#, ) .unwrap(); @@ -62,14 +62,11 @@ async fn isolation_seeds_stripped_account_and_credentials_from_files() { clear_config_dir(); response.outcome.expect("isolated claude run succeeds"); - assert_eq!( - std::fs::read_to_string(&cred_path).unwrap(), - r#"{"token": "abc"}"# - ); + assert_eq!(std::fs::read_to_string(&cred_path).unwrap(), "abc"); assert_eq!( std::fs::read_to_string(&modes_path).unwrap(), - "drwx------\n-rw-------\n", - "config dir mode then credentials file mode" + "drwx------\n.claude.json\n", + "config dir mode then its only entry" ); let account: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&account_path).unwrap()).unwrap(); @@ -95,7 +92,7 @@ async fn empty_claude_config_dir_uses_the_default_home() { std::fs::create_dir(source.path().join(".claude")).unwrap(); std::fs::write( source.path().join(".claude/.credentials.json"), - r#"{"token": "home-token"}"#, + r#"{"claudeAiOauth": {"accessToken": "home-token"}}"#, ) .unwrap(); let cred_out = tempfile::NamedTempFile::new().unwrap(); @@ -115,10 +112,7 @@ async fn empty_claude_config_dir_uses_the_default_home() { } response.outcome.expect("isolated claude run succeeds"); - assert_eq!( - std::fs::read_to_string(&cred_path).unwrap(), - r#"{"token": "home-token"}"# - ); + assert_eq!(std::fs::read_to_string(&cred_path).unwrap(), "home-token"); } #[cfg(target_os = "macos")] diff --git a/spawnllm/backends/base.py b/spawnllm/backends/base.py index 082ab4a..8e482dd 100644 --- a/spawnllm/backends/base.py +++ b/spawnllm/backends/base.py @@ -83,6 +83,20 @@ class BackendCallError(RuntimeError): """ +@dataclass(frozen=True) +class ClaudeIsolation: + """A seeded throwaway config home and the env a claude run adds beside it. + + Attributes: + config_dir: Path substituted for `${isolated_config_dir}` in the plan. + env: Variables the run sets on the child, such as `CLAUDE_CODE_OAUTH_TOKEN` + carrying the claude.ai access token; never written to disk. + """ + + config_dir: str + env: dict[str, str] + + @dataclass(frozen=True) class Invocation: """A built CLI invocation: argv, optional stdin, and where to read the result. @@ -382,8 +396,8 @@ def binary_path(self) -> str: """ return self.binary - def claude_isolation(self) -> str: - """Return the isolated config home a claude run substitutes into `${isolated_config_dir}`.""" + def claude_isolation(self) -> ClaudeIsolation: + """Return the config home a claude run substitutes into `${isolated_config_dir}` and the env it adds.""" raise NotImplementedError def build_command(self, spec: RunSpec) -> list[str]: @@ -422,9 +436,11 @@ def invocation(self, spec: RunSpec) -> Invocation: argv = [tokens.get(arg, arg) for arg in plan["argv"]] env = plan["env"] if plan["needs_claude_isolation"]: - directory = self.claude_isolation() - argv = [arg.replace("${isolated_config_dir}", directory) for arg in argv] - env = {key: value.replace("${isolated_config_dir}", directory) for key, value in env.items()} + isolation = self.claude_isolation() + argv = [arg.replace("${isolated_config_dir}", isolation.config_dir) for arg in argv] + env = { + key: value.replace("${isolated_config_dir}", isolation.config_dir) for key, value in env.items() + } | isolation.env except BaseException: for path in paths.values(): Path(path).unlink(missing_ok=True) @@ -446,13 +462,16 @@ def env(self, spec: RunSpec) -> dict[str, str]: spec: The configured run; the plan gates isolation on `spec.isolated`. Returns: - The plan's env map with `${isolated_config_dir}` resolved, or `{}`. + The plan's env map with `${isolated_config_dir}` resolved plus the + isolation's own entries, or `{}`. """ plan = self.core_plan(spec) if not plan["needs_claude_isolation"]: return plan["env"] - directory = self.claude_isolation() - return {key: value.replace("${isolated_config_dir}", directory) for key, value in plan["env"].items()} + isolation = self.claude_isolation() + return { + key: value.replace("${isolated_config_dir}", isolation.config_dir) for key, value in plan["env"].items() + } | isolation.env def accounting(self, raw: str) -> tuple[float | None, dict[str, object] | None]: """Return the `(cost_usd, usage)` the core's `resolve` op reads from `raw`.""" diff --git a/spawnllm/backends/claude.py b/spawnllm/backends/claude.py index 3e22cd7..e1a57d6 100644 --- a/spawnllm/backends/claude.py +++ b/spawnllm/backends/claude.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, ClassVar from spawnllm import _core -from spawnllm.backends.base import CliBackend +from spawnllm.backends.base import ClaudeIsolation, CliBackend if TYPE_CHECKING: from spawnllm.types import ProviderName, TModel @@ -43,7 +43,8 @@ class ClaudeCliBackend(CliBackend): The core plans the `claude -p` argv (prompt delivered over stdin, result read from a stdout file) and lays out the host-free config home this backend seeds - with only the active-account pointer and claude.ai OAuth token. + with only the active-account pointer; the claude.ai access token reaches the + child as `CLAUDE_CODE_OAUTH_TOKEN`, never as a file. Attributes: models: Mapping from abstract model size to a Claude model alias @@ -61,20 +62,21 @@ class ClaudeCliBackend(CliBackend): install_hint: ClassVar[str] = "curl -fsSL https://claude.ai/install.sh | bash" schema_dialect: ClassVar[str | None] = "anthropic" - _isolated_config_dir: str | None = None + _isolation: ClaudeIsolation | None = None - def claude_isolation(self) -> str: - """Return the process-lifetime isolated config home, creating and seeding it once. + def claude_isolation(self) -> ClaudeIsolation: + """Return the process-lifetime isolation, creating and seeding its config home once. The core's `claude_isolation_sources` op resolves the account pointer, credentials file, and Keychain service from the caller's effective config home; this host reads those sources (falling back to the Keychain when the - credentials file is absent), hands them to `claude_isolation_seed` for the - exact files-and-modes to write, and materializes them into a fresh temp - dir removed at interpreter exit. The dir is cached on the backend. + credentials file is absent) and hands them to `claude_isolation_seed` for + the exact files-and-modes to write and the env to set. The files land in a + fresh temp dir removed at interpreter exit; the token only ever lives in + the env. The result is cached on the backend. """ - if self._isolated_config_dir is not None: - return self._isolated_config_dir + if self._isolation is not None: + return self._isolation sources = _core.dispatch( "claude_isolation_sources", { @@ -100,5 +102,5 @@ def claude_isolation(self) -> str: with os.fdopen(fd, "w") as handle: handle.write(file["content"]) atexit.register(shutil.rmtree, config_dir, ignore_errors=True) - self._isolated_config_dir = str(config_dir) - return self._isolated_config_dir + self._isolation = ClaudeIsolation(str(config_dir), seed["env"]) + return self._isolation diff --git a/tests/test_backends.py b/tests/test_backends.py index 990cc82..4957899 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -4,6 +4,9 @@ import hashlib import json import os +import subprocess +import sys +import time from collections.abc import Callable from pathlib import Path @@ -308,13 +311,10 @@ def test_env_isolates_and_seeds_config_dir_from_home(self, tmp_path: Path, monke assert config_dir.is_dir() assert config_dir.stat().st_mode & 0o777 == 0o700 assert backend.env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"] == str(config_dir) - # The token is substituted in place of the plan's ${isolated_config_dir} placeholder. assert "${isolated_config_dir}" not in env["CLAUDE_CONFIG_DIR"] - # The account pointer is seeded sans host mcpServers; the OAuth token comes along. assert json.loads((config_dir / ".claude.json").read_text()) == {"oauthAccount": {"accountUuid": "a"}} - credentials = config_dir / ".credentials.json" - assert json.loads(credentials.read_text()) == {"claudeAiOauth": {"accessToken": "tok"}} - assert credentials.stat().st_mode & 0o777 == 0o600 + assert env["CLAUDE_CODE_OAUTH_TOKEN"] == "tok" + assert sorted(path.name for path in config_dir.iterdir()) == [".claude.json"] @staticmethod def fail_on_keychain_call(argv: list[str], **kwargs: object) -> object: @@ -339,7 +339,7 @@ def observing_open(path: str | os.PathLike[str], flags: int, mode: int = 0o777) monkeypatch.setattr("spawnllm.backends.claude.os.open", observing_open) ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku")) - assert created == [(".claude.json", 0o644, 0), (".credentials.json", 0o600, 0)] + assert created == [(".claude.json", 0o644, 0)] def test_env_seeds_from_claude_config_dir_over_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("HOME", str(tmp_path / "home")) @@ -347,11 +347,11 @@ def test_env_seeds_from_claude_config_dir_over_home(self, tmp_path: Path, monkey monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(account_home)) (account_home / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "b"}})) (account_home / ".credentials.json").write_text('{"claudeAiOauth": {"accessToken": "acct-tok"}}') - config_dir = Path(ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"]) + env = ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku")) + config_dir = Path(env["CLAUDE_CONFIG_DIR"]) assert json.loads((config_dir / ".claude.json").read_text()) == {"oauthAccount": {"accountUuid": "b"}} - assert json.loads((config_dir / ".credentials.json").read_text()) == { - "claudeAiOauth": {"accessToken": "acct-tok"} - } + assert env["CLAUDE_CODE_OAUTH_TOKEN"] == "acct-tok" + assert not (config_dir / ".credentials.json").exists() def test_env_default_home_falls_back_to_the_bare_keychain_item( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -368,11 +368,10 @@ def fake_run(argv: list[str], **kwargs: object) -> object: return type("P", (), {"returncode": 0, "stdout": '{"claudeAiOauth": {"accessToken": "kc-tok"}}\n'})() monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", fake_run) - config_dir = Path(ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"]) + env = ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku")) assert calls == [["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"]] - credentials = config_dir / ".credentials.json" - assert json.loads(credentials.read_text()) == {"claudeAiOauth": {"accessToken": "kc-tok"}} - assert credentials.stat().st_mode & 0o777 == 0o600 + assert env["CLAUDE_CODE_OAUTH_TOKEN"] == "kc-tok" + assert not (Path(env["CLAUDE_CONFIG_DIR"]) / ".credentials.json").exists() def test_env_config_dir_falls_back_to_the_suffixed_keychain_item( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -388,12 +387,11 @@ def fake_run(argv: list[str], **kwargs: object) -> object: return type("P", (), {"returncode": 0, "stdout": '{"claudeAiOauth": {"accessToken": "kc-tok"}}\n'})() monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", fake_run) - config_dir = Path(ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"]) + env = ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku")) service = suffixed_keychain_service(str(account_home)) assert calls == [["security", "find-generic-password", "-s", service, "-w"]] - credentials = config_dir / ".credentials.json" - assert json.loads(credentials.read_text()) == {"claudeAiOauth": {"accessToken": "kc-tok"}} - assert credentials.stat().st_mode & 0o777 == 0o600 + assert env["CLAUDE_CODE_OAUTH_TOKEN"] == "kc-tok" + assert not (Path(env["CLAUDE_CONFIG_DIR"]) / ".credentials.json").exists() @pytest.mark.parametrize( "config_dir_env, expected_service", @@ -459,12 +457,10 @@ def fake_run(argv: list[str], **kwargs: object) -> object: return type("P", (), {"returncode": 0, "stdout": '{"claudeAiOauth": {"accessToken": "kc-tok"}}\n'})() monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", fake_run) - config_dir = Path(ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"]) + env = ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku")) service = expected_service(str(home), str(acct)) assert calls == [["security", "find-generic-password", "-s", service, "-w"]] - assert json.loads((config_dir / ".credentials.json").read_text()) == { - "claudeAiOauth": {"accessToken": "kc-tok"} - } + assert env["CLAUDE_CODE_OAUTH_TOKEN"] == "kc-tok" def test_env_keychain_miss_seeds_no_credentials(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (account_home := tmp_path / "acct").mkdir() @@ -475,12 +471,43 @@ def test_env_keychain_miss_seeds_no_credentials(self, tmp_path: Path, monkeypatc "spawnllm.backends.claude.subprocess.run", lambda *a, **k: type("P", (), {"returncode": 44, "stdout": ""})(), ) - config_dir = Path(ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"]) - assert not (config_dir / ".credentials.json").exists() + env = ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku")) + assert "CLAUDE_CODE_OAUTH_TOKEN" not in env + assert not (Path(env["CLAUDE_CONFIG_DIR"]) / ".credentials.json").exists() def test_env_non_isolated_adds_nothing(self) -> None: assert ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku", isolated=False)) == {} + def test_killed_run_leaves_no_token_on_disk(self, tmp_path: Path) -> None: + token = f"leak-{os.urandom(8).hex()}" + (home := tmp_path / "home").mkdir() + (home / ".claude").mkdir() + (home / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "k"}})) + (home / ".claude" / ".credentials.json").write_text(json.dumps({"claudeAiOauth": {"accessToken": token}})) + (bin_dir := tmp_path / "bin").mkdir() + (tmpdir := tmp_path / "tmp").mkdir() + started = tmp_path / "started" + fake = bin_dir / "claude" + fake.write_text(f'#!/bin/sh\nprintf "%s\n%s" "$$" "$CLAUDE_CONFIG_DIR" > {started}\nsleep 60\n') + fake.chmod(0o755) + env = {name: value for name, value in os.environ.items() if not name.startswith("CLAUDE_")} | { + "HOME": str(home), + "TMPDIR": str(tmpdir), + "PATH": f"{bin_dir}:{os.environ['PATH']}", + } + run = "from spawnllm import ClaudeCliBackend, RunSpec; ClaudeCliBackend().execute(RunSpec('hi', model='haiku'))" + parent = subprocess.Popen([sys.executable, "-c", run], env=env) + deadline = time.monotonic() + 30 + while not started.exists() and time.monotonic() < deadline: + time.sleep(0.05) + parent.kill() + parent.wait() + pid, config_dir = started.read_text().split("\n") + os.kill(int(pid), 9) + assert Path(config_dir).is_relative_to(tmpdir) + assert Path(config_dir).is_dir() + assert not [path for path in tmpdir.rglob("*") if path.is_file() and token in path.read_text()] + class TestModels: def test_claude(self) -> None: From 5feaa1b93a509c71722878a7cab897120d1ff15f Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Thu, 17 Sep 2026 13:14:13 -0700 Subject: [PATCH 2/3] =?UTF-8?q?claude:=20=F0=9F=90=9B=20preserve=20OAuth?= =?UTF-8?q?=20token=20precedence=20and=20resolve=20tokens=20per=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: Review of 161b85b found that the seed environment overwrote a host CLAUDE_CODE_OAUTH_TOKEN and Python cached the resolved token for the backend's lifetime. Summary: Skip credential sources in the core when the host token is non-empty. Make credentials_path nullable in all 3 hosts. Cache only Python's config directory, resolving sources, credentials, and the seed environment on every call. Motivation: Honor Claude Code's environment-token precedence and let long-lived Python backends pick up renewed credentials. RunSpec.env already applies last in all 3 hosts and continues to override the child environment. Details: Add 2 sources vectors, update 13 existing vectors, and cover inherited tokens in all 3 hosts plus Python token renewal. Clear ambient tokens in isolation tests, publish the SIGKILL marker with write-then-mv, kill the parent in finally, and extend the Security changelog entry. Keep malformed stored JSON errors, expiry handling, and operator cleanup unchanged. Claude-Session-Id: 1dd4ee33-1b87-4c1f-af93-7a8827b07ef5 --- CHANGELOG.md | 14 +++-- .../config-dir-env-darwin.json | 3 +- .../config-dir-env-decomposed-darwin.json | 3 +- .../config-dir-env-default-path-darwin.json | 3 +- .../config-dir-env-empty-darwin.json | 3 +- .../config-dir-env-linux.json | 3 +- .../config-dir-env-trailing-slash-darwin.json | 3 +- .../custom-oauth-url-darwin.json | 3 +- .../custom-oauth-url-empty-darwin.json | 3 +- .../default-home-darwin.json | 3 +- .../default-home-linux.json | 3 +- .../oauth-token-env-darwin.json | 19 +++++++ .../oauth-token-env-empty-darwin.json | 19 +++++++ .../securestorage-env-darwin.json | 3 +- ...rage-env-empty-over-config-dir-darwin.json | 3 +- ...urestorage-env-over-config-dir-darwin.json | 3 +- go/coreops.go | 6 ++- go/exec_test.go | 54 ++++++++++++++++--- go/isolate.go | 5 +- rust/conformance-gen/src/cases.rs | 17 ++++++ rust/spawnllm-core/src/isolate.rs | 10 ++-- rust/spawnllm/src/isolate.rs | 13 +++-- rust/spawnllm/tests/isolation.rs | 54 ++++++++++++++++++- spawnllm/backends/claude.py | 39 +++++++------- tests/test_backends.py | 46 +++++++++++++--- 25 files changed, 276 insertions(+), 59 deletions(-) create mode 100644 conformance/vectors/claude_isolation_sources/oauth-token-env-darwin.json create mode 100644 conformance/vectors/claude_isolation_sources/oauth-token-env-empty-darwin.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da4104..ccfa948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 access token back as an `env` map, `CLAUDE_CODE_OAUTH_TOKEN`, that every host sets on the child, and seeds only the account pointer; nothing secret touches the filesystem, a leaked dir holds no credential, and the child - stores nothing in the Keychain. Claude Code authenticates from that - variable without refreshing it, so a run started after the Keychain's - access token expired fails with its `401 OAuth access token is invalid` - instead of refreshing; any Claude Code session on the machine keeps the - Keychain token current. + stores nothing in the Keychain. A `CLAUDE_CODE_OAUTH_TOKEN` already set + on the host process outranks the stored credential, as it does in Claude + Code, so the seed reads no credential source and the child inherits it. + The Python host resolves the token on every run rather than once per + process, so a renewed Keychain token reaches the next run of a long-lived + backend. Claude Code authenticates from that variable without refreshing + it, so a run started after the Keychain's access token expired fails with + its `401 OAuth access token is invalid` instead of refreshing; any Claude + Code session on the machine keeps the Keychain token current. ## [0.13.3] - 2026-09-17 diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-darwin.json b/conformance/vectors/claude_isolation_sources/config-dir-env-darwin.json index 5acda06..37bcd2b 100644 --- a/conformance/vectors/claude_isolation_sources/config-dir-env-darwin.json +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-darwin.json @@ -7,7 +7,8 @@ "home": "/Users/testuser", "claude_config_dir_env": "/Users/testuser/.acct", "claude_securestorage_config_dir_env": null, - "claude_code_custom_oauth_url_env": null + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-decomposed-darwin.json b/conformance/vectors/claude_isolation_sources/config-dir-env-decomposed-darwin.json index 06cc355..666f096 100644 --- a/conformance/vectors/claude_isolation_sources/config-dir-env-decomposed-darwin.json +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-decomposed-darwin.json @@ -7,7 +7,8 @@ "home": "/Users/testuser", "claude_config_dir_env": "/Users/testuser/résumé", "claude_securestorage_config_dir_env": null, - "claude_code_custom_oauth_url_env": null + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-default-path-darwin.json b/conformance/vectors/claude_isolation_sources/config-dir-env-default-path-darwin.json index ff515b3..bfdf3d7 100644 --- a/conformance/vectors/claude_isolation_sources/config-dir-env-default-path-darwin.json +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-default-path-darwin.json @@ -7,7 +7,8 @@ "home": "/Users/testuser", "claude_config_dir_env": "/Users/testuser/.claude", "claude_securestorage_config_dir_env": null, - "claude_code_custom_oauth_url_env": null + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-empty-darwin.json b/conformance/vectors/claude_isolation_sources/config-dir-env-empty-darwin.json index 8e681f7..20875f4 100644 --- a/conformance/vectors/claude_isolation_sources/config-dir-env-empty-darwin.json +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-empty-darwin.json @@ -7,7 +7,8 @@ "home": "/Users/testuser", "claude_config_dir_env": "", "claude_securestorage_config_dir_env": null, - "claude_code_custom_oauth_url_env": null + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-linux.json b/conformance/vectors/claude_isolation_sources/config-dir-env-linux.json index 4a50449..f25b1ca 100644 --- a/conformance/vectors/claude_isolation_sources/config-dir-env-linux.json +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-linux.json @@ -7,7 +7,8 @@ "home": "/home/testuser", "claude_config_dir_env": "/home/testuser/.acct", "claude_securestorage_config_dir_env": null, - "claude_code_custom_oauth_url_env": null + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/config-dir-env-trailing-slash-darwin.json b/conformance/vectors/claude_isolation_sources/config-dir-env-trailing-slash-darwin.json index 43e244b..45ecfee 100644 --- a/conformance/vectors/claude_isolation_sources/config-dir-env-trailing-slash-darwin.json +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-trailing-slash-darwin.json @@ -7,7 +7,8 @@ "home": "/Users/testuser", "claude_config_dir_env": "/Users/testuser/.acct/", "claude_securestorage_config_dir_env": null, - "claude_code_custom_oauth_url_env": null + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/custom-oauth-url-darwin.json b/conformance/vectors/claude_isolation_sources/custom-oauth-url-darwin.json index 219bc9e..7060026 100644 --- a/conformance/vectors/claude_isolation_sources/custom-oauth-url-darwin.json +++ b/conformance/vectors/claude_isolation_sources/custom-oauth-url-darwin.json @@ -7,7 +7,8 @@ "home": "/Users/testuser", "claude_config_dir_env": null, "claude_securestorage_config_dir_env": null, - "claude_code_custom_oauth_url_env": "https://oauth.example.test" + "claude_code_custom_oauth_url_env": "https://oauth.example.test", + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/custom-oauth-url-empty-darwin.json b/conformance/vectors/claude_isolation_sources/custom-oauth-url-empty-darwin.json index 2792ae8..38dc3a4 100644 --- a/conformance/vectors/claude_isolation_sources/custom-oauth-url-empty-darwin.json +++ b/conformance/vectors/claude_isolation_sources/custom-oauth-url-empty-darwin.json @@ -7,7 +7,8 @@ "home": "/Users/testuser", "claude_config_dir_env": "/Users/testuser/.acct", "claude_securestorage_config_dir_env": null, - "claude_code_custom_oauth_url_env": "" + "claude_code_custom_oauth_url_env": "", + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/default-home-darwin.json b/conformance/vectors/claude_isolation_sources/default-home-darwin.json index c9d6057..d19491a 100644 --- a/conformance/vectors/claude_isolation_sources/default-home-darwin.json +++ b/conformance/vectors/claude_isolation_sources/default-home-darwin.json @@ -7,7 +7,8 @@ "home": "/Users/testuser", "claude_config_dir_env": null, "claude_securestorage_config_dir_env": null, - "claude_code_custom_oauth_url_env": null + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/default-home-linux.json b/conformance/vectors/claude_isolation_sources/default-home-linux.json index e06aed3..b2a6633 100644 --- a/conformance/vectors/claude_isolation_sources/default-home-linux.json +++ b/conformance/vectors/claude_isolation_sources/default-home-linux.json @@ -7,7 +7,8 @@ "home": "/home/testuser", "claude_config_dir_env": null, "claude_securestorage_config_dir_env": null, - "claude_code_custom_oauth_url_env": null + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/oauth-token-env-darwin.json b/conformance/vectors/claude_isolation_sources/oauth-token-env-darwin.json new file mode 100644 index 0000000..f686076 --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/oauth-token-env-darwin.json @@ -0,0 +1,19 @@ +{ + "name": "oauth-token-env-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": null, + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": "sk-ant-oat01-inherited" + } + }, + "expected": { + "account_path": "/Users/testuser/.claude.json", + "credentials_path": null, + "keychain_service": null + } +} diff --git a/conformance/vectors/claude_isolation_sources/oauth-token-env-empty-darwin.json b/conformance/vectors/claude_isolation_sources/oauth-token-env-empty-darwin.json new file mode 100644 index 0000000..a91a74f --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/oauth-token-env-empty-darwin.json @@ -0,0 +1,19 @@ +{ + "name": "oauth-token-env-empty-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": null, + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": "" + } + }, + "expected": { + "account_path": "/Users/testuser/.claude.json", + "credentials_path": "/Users/testuser/.claude/.credentials.json", + "keychain_service": "Claude Code-credentials" + } +} diff --git a/conformance/vectors/claude_isolation_sources/securestorage-env-darwin.json b/conformance/vectors/claude_isolation_sources/securestorage-env-darwin.json index 92a2c9a..e83f0a1 100644 --- a/conformance/vectors/claude_isolation_sources/securestorage-env-darwin.json +++ b/conformance/vectors/claude_isolation_sources/securestorage-env-darwin.json @@ -7,7 +7,8 @@ "home": "/Users/testuser", "claude_config_dir_env": null, "claude_securestorage_config_dir_env": "/Users/testuser/.secure", - "claude_code_custom_oauth_url_env": null + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/securestorage-env-empty-over-config-dir-darwin.json b/conformance/vectors/claude_isolation_sources/securestorage-env-empty-over-config-dir-darwin.json index b021c7b..42bb38c 100644 --- a/conformance/vectors/claude_isolation_sources/securestorage-env-empty-over-config-dir-darwin.json +++ b/conformance/vectors/claude_isolation_sources/securestorage-env-empty-over-config-dir-darwin.json @@ -7,7 +7,8 @@ "home": "/Users/testuser", "claude_config_dir_env": "/Users/testuser/.acct", "claude_securestorage_config_dir_env": "", - "claude_code_custom_oauth_url_env": null + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/securestorage-env-over-config-dir-darwin.json b/conformance/vectors/claude_isolation_sources/securestorage-env-over-config-dir-darwin.json index 426dad6..5ac6061 100644 --- a/conformance/vectors/claude_isolation_sources/securestorage-env-over-config-dir-darwin.json +++ b/conformance/vectors/claude_isolation_sources/securestorage-env-over-config-dir-darwin.json @@ -7,7 +7,8 @@ "home": "/Users/testuser", "claude_config_dir_env": "/Users/testuser/.acct", "claude_securestorage_config_dir_env": "/Users/testuser/.secure/", - "claude_code_custom_oauth_url_env": null + "claude_code_custom_oauth_url_env": null, + "claude_code_oauth_token_env": null } }, "expected": { diff --git a/go/coreops.go b/go/coreops.go index 7baf15e..9c888e1 100644 --- a/go/coreops.go +++ b/go/coreops.go @@ -95,7 +95,7 @@ type authProbes struct { type isolationSources struct { AccountPath string `json:"account_path"` - CredentialsPath string `json:"credentials_path"` + CredentialsPath *string `json:"credentials_path"` KeychainService *string `json:"keychain_service"` } @@ -205,6 +205,7 @@ func coreIsolationSources() (isolationSources, error) { "claude_config_dir_env": nil, "claude_securestorage_config_dir_env": nil, "claude_code_custom_oauth_url_env": nil, + "claude_code_oauth_token_env": nil, } if dir := configDirEnv(); dir != "" { host["claude_config_dir_env"] = dir @@ -215,6 +216,9 @@ func coreIsolationSources() (isolationSources, error) { if url, defined := os.LookupEnv("CLAUDE_CODE_CUSTOM_OAUTH_URL"); defined { host["claude_code_custom_oauth_url_env"] = url } + if token, defined := os.LookupEnv("CLAUDE_CODE_OAUTH_TOKEN"); defined { + host["claude_code_oauth_token_env"] = token + } return coreInto[isolationSources]("claude_isolation_sources", struct { Host map[string]any `json:"host"` }{Host: host}) diff --git a/go/exec_test.go b/go/exec_test.go index 38f3900..41d1c42 100644 --- a/go/exec_test.go +++ b/go/exec_test.go @@ -275,11 +275,22 @@ func TestTransientThenSuccessRetry(t *testing.T) { } } +func clearHostEnv(t *testing.T, names ...string) { + t.Helper() + for _, name := range names { + t.Setenv(name, "") + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } + } +} + func TestClaudeIsolationSeeding(t *testing.T) { withFakeBin(t) home := t.TempDir() t.Setenv("HOME", home) t.Setenv("CLAUDE_CONFIG_DIR", "") + clearHostEnv(t, "CLAUDE_CODE_OAUTH_TOKEN") if err := os.WriteFile(filepath.Join(home, ".claude.json"), []byte(`{"oauthAccount":{"accountUuid":"a"},"mcpServers":{"s":{"command":"x"}}}`), 0o644); err != nil { t.Fatal(err) @@ -340,6 +351,7 @@ func keychainSeededRun(t *testing.T, service string) (claudeOutput, string) { t.Skip("the Keychain fallback runs only on darwin") } withFakeBin(t) + clearHostEnv(t, "CLAUDE_CODE_OAUTH_TOKEN") argvOut := filepath.Join(t.TempDir(), "argv") t.Setenv("FAKE_SECURITY_ARGV_OUT", argvOut) t.Setenv("FAKE_KEYCHAIN_SERVICE", service) @@ -432,12 +444,7 @@ func TestClaudeIsolationKeychain(t *testing.T) { } writeAccountPointer(t, home) writeAccountPointer(t, acct) - for _, name := range []string{"CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CODE_CUSTOM_OAUTH_URL"} { - t.Setenv(name, "") - if err := os.Unsetenv(name); err != nil { - t.Fatal(err) - } - } + clearHostEnv(t, "CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CODE_CUSTOM_OAUTH_URL") for name, value := range tc.env(home, acct) { t.Setenv(name, value) } @@ -470,6 +477,41 @@ func TestClaudeIsolationKeychainMissSeedsNoCredentials(t *testing.T) { } } +func TestClaudeIsolationInheritedTokenReadsNoCredentialSource(t *testing.T) { + withFakeBin(t) + home := t.TempDir() + t.Setenv("HOME", home) + clearHostEnv(t, "CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CODE_CUSTOM_OAUTH_URL") + t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "inherited") + writeAccountPointer(t, home) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".claude", ".credentials.json"), []byte("not json"), 0o600); err != nil { + t.Fatal(err) + } + argvOut := filepath.Join(t.TempDir(), "argv") + t.Setenv("FAKE_SECURITY_ARGV_OUT", argvOut) + + resp, err := RunOn(context.Background(), ClaudeBackend(), RunSpec{Prompt: "iso", Model: "haiku"}) + if err != nil { + t.Fatalf("RunOn: %v", err) + } + if resp.Err != nil { + t.Fatalf("unexpected provider error: %v", resp.Err) + } + var out claudeOutput + if err := json.Unmarshal([]byte(resp.Output), &out); err != nil { + t.Fatalf("decode output %q: %v", resp.Output, err) + } + if out.OauthToken != "inherited" || out.CredsPresent { + t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN = %q, credentials file present = %v; want the inherited token and no file", out.OauthToken, out.CredsPresent) + } + if _, err := os.Stat(argvOut); !os.IsNotExist(err) { + t.Fatalf("an inherited token must skip the Keychain: stat err = %v", err) + } +} + type echoResult struct { Echo string `json:"echo"` } diff --git a/go/isolate.go b/go/isolate.go index f3388ee..0332863 100644 --- a/go/isolate.go +++ b/go/isolate.go @@ -15,7 +15,10 @@ func seedClaudeIsolation() (string, map[string]string, func(), error) { return "", nil, nil, err } accountJSON := readFileOpt(sources.AccountPath) - credentialsJSON := readFileOpt(sources.CredentialsPath) + var credentialsJSON *string + if sources.CredentialsPath != nil { + credentialsJSON = readFileOpt(*sources.CredentialsPath) + } if credentialsJSON == nil && sources.KeychainService != nil { credentialsJSON = keychainCredentials(*sources.KeychainService) } diff --git a/rust/conformance-gen/src/cases.rs b/rust/conformance-gen/src/cases.rs index 8460f07..5aa34e4 100644 --- a/rust/conformance-gen/src/cases.rs +++ b/rust/conformance-gen/src/cases.rs @@ -1030,6 +1030,7 @@ struct IsoHost { config_dir_env: Option<&'static str>, securestorage_config_dir_env: Option<&'static str>, custom_oauth_url_env: Option<&'static str>, + oauth_token_env: Option<&'static str>, } const ISO_DARWIN: IsoHost = IsoHost { @@ -1038,6 +1039,7 @@ const ISO_DARWIN: IsoHost = IsoHost { config_dir_env: None, securestorage_config_dir_env: None, custom_oauth_url_env: None, + oauth_token_env: None, }; const ISO_LINUX: IsoHost = IsoHost { @@ -1057,6 +1059,7 @@ fn iso_sources_case(name: &str, host: IsoHost) -> Case { "claude_config_dir_env": host.config_dir_env, "claude_securestorage_config_dir_env": host.securestorage_config_dir_env, "claude_code_custom_oauth_url_env": host.custom_oauth_url_env, + "claude_code_oauth_token_env": host.oauth_token_env, } }), } @@ -1146,6 +1149,20 @@ fn iso_sources_cases() -> Vec { ..ISO_DARWIN }, ), + iso_sources_case( + "oauth-token-env-darwin", + IsoHost { + oauth_token_env: Some("sk-ant-oat01-inherited"), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "oauth-token-env-empty-darwin", + IsoHost { + oauth_token_env: Some(""), + ..ISO_DARWIN + }, + ), ] } diff --git a/rust/spawnllm-core/src/isolate.rs b/rust/spawnllm-core/src/isolate.rs index 6f534c6..fca7fd2 100644 --- a/rust/spawnllm-core/src/isolate.rs +++ b/rust/spawnllm-core/src/isolate.rs @@ -23,12 +23,14 @@ struct IsolationHost { claude_securestorage_config_dir_env: Option, #[serde(default)] claude_code_custom_oauth_url_env: Option, + #[serde(default)] + claude_code_oauth_token_env: Option, } #[derive(Debug, Serialize)] struct IsolationSources { account_path: String, - credentials_path: String, + credentials_path: Option, keychain_service: Option, } @@ -112,6 +114,7 @@ fn config_dir_digest(config_dir_env: &str) -> String { // overrides CLAUDE_CONFIG_DIR for the storage dir and the digest of the NFC value as set. fn isolation_sources(input: IsolationSourcesInput) -> IsolationSources { let host = input.host; + let inherits_token = is_set(&host.claude_code_oauth_token_env); let default_home = format!("{}/.claude", host.home); let (account_path, config_home) = match &host.claude_config_dir_env { Some(config_dir_env) if !config_dir_env.is_empty() => { @@ -138,13 +141,14 @@ fn isolation_sources(input: IsolationSourcesInput) -> IsolationSources { } else { "" }; - let keychain_service = (host.platform == "darwin").then(|| { + let keychain_service = (host.platform == "darwin" && !inherits_token).then(|| { let digest = hashed_dir.map(config_dir_digest).unwrap_or_default(); format!("Claude Code{oauth_file_suffix}-credentials{digest}") }); IsolationSources { account_path, - credentials_path: format!("{credentials_home}/.credentials.json"), + credentials_path: (!inherits_token) + .then(|| format!("{credentials_home}/.credentials.json")), keychain_service, } } diff --git a/rust/spawnllm/src/isolate.rs b/rust/spawnllm/src/isolate.rs index 53d6d41..01bdef3 100644 --- a/rust/spawnllm/src/isolate.rs +++ b/rust/spawnllm/src/isolate.rs @@ -18,7 +18,7 @@ use crate::host::{home, platform}; #[derive(Debug, Deserialize)] struct Sources { account_path: String, - credentials_path: String, + credentials_path: Option, keychain_service: Option, } @@ -49,13 +49,18 @@ pub(crate) async fn seed_isolation() -> Result { "claude_config_dir_env": std::env::var("CLAUDE_CONFIG_DIR").ok().filter(|value| !value.is_empty()), "claude_securestorage_config_dir_env": std::env::var("CLAUDE_SECURESTORAGE_CONFIG_DIR").ok(), "claude_code_custom_oauth_url_env": std::env::var("CLAUDE_CODE_CUSTOM_OAUTH_URL").ok(), + "claude_code_oauth_token_env": std::env::var("CLAUDE_CODE_OAUTH_TOKEN").ok(), } }), )?; let account_json = std::fs::read_to_string(&sources.account_path).ok(); - let credentials_json = match std::fs::read_to_string(&sources.credentials_path) { - Ok(text) => Some(text), - Err(_) => match &sources.keychain_service { + let credentials_json = match sources + .credentials_path + .as_deref() + .and_then(|path| std::fs::read_to_string(path).ok()) + { + Some(text) => Some(text), + None => match &sources.keychain_service { Some(service) => keychain_credentials(service).await, None => None, }, diff --git a/rust/spawnllm/tests/isolation.rs b/rust/spawnllm/tests/isolation.rs index 8117b80..d7428ad 100644 --- a/rust/spawnllm/tests/isolation.rs +++ b/rust/spawnllm/tests/isolation.rs @@ -8,7 +8,10 @@ use spawnllm::{Backend, RunSpec}; fn set_config_dir(dir: &Path) { // SAFETY: gated by ENV_LOCK; this process runs only the isolation tests serially. - unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", dir) }; + unsafe { + std::env::remove_var("CLAUDE_CODE_OAUTH_TOKEN"); + std::env::set_var("CLAUDE_CONFIG_DIR", dir); + } } fn clear_config_dir() { @@ -100,6 +103,7 @@ async fn empty_claude_config_dir_uses_the_default_home() { let original_home = std::env::var_os("HOME"); unsafe { + std::env::remove_var("CLAUDE_CODE_OAUTH_TOKEN"); std::env::set_var("HOME", source.path()); std::env::set_var("CLAUDE_CONFIG_DIR", ""); } @@ -124,10 +128,11 @@ fn suffixed_keychain_service(config_dir_env: &str) -> String { } #[cfg(target_os = "macos")] -const HOST_KEYCHAIN_VARS: [&str; 3] = [ +const HOST_KEYCHAIN_VARS: [&str; 4] = [ "CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CODE_CUSTOM_OAUTH_URL", + "CLAUDE_CODE_OAUTH_TOKEN", ]; #[cfg(target_os = "macos")] @@ -358,3 +363,48 @@ async fn keychain_miss_seeds_no_credentials() { ) ); } + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn inherited_oauth_token_reads_no_credential_source() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir(home.path().join(".claude")).unwrap(); + std::fs::write(home.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + std::fs::write(home.path().join(".claude/.credentials.json"), "not json").unwrap(); + let cred_out = tempfile::NamedTempFile::new().unwrap(); + let cred_path = cred_out.path().to_str().unwrap().to_owned(); + let argv_out = tempfile::tempdir().unwrap(); + let argv_path = argv_out.path().join("argv"); + let original_home = std::env::var_os("HOME"); + unsafe { + for var in HOST_KEYCHAIN_VARS { + std::env::remove_var(var); + } + std::env::set_var("HOME", home.path()); + std::env::set_var("CLAUDE_CODE_OAUTH_TOKEN", "inherited"); + std::env::set_var("SPAWNLLM_FAKE_SECURITY_ARGV_OUT", &argv_path); + } + let spec = RunSpec::new("hi", "haiku").env(env(&[("SPAWNLLM_FAKE_CRED_OUT", &cred_path)])); + let response = spawnllm::run_on(&Backend::Claude, spec).await; + unsafe { + std::env::remove_var("CLAUDE_CODE_OAUTH_TOKEN"); + std::env::remove_var("SPAWNLLM_FAKE_SECURITY_ARGV_OUT"); + } + match original_home { + Some(value) => unsafe { std::env::set_var("HOME", value) }, + None => unsafe { std::env::remove_var("HOME") }, + } + + response.outcome.expect("isolated claude run succeeds"); + assert_eq!(std::fs::read_to_string(&cred_path).unwrap(), "inherited"); + assert!( + !argv_path.exists(), + "an inherited token must skip the Keychain" + ); +} diff --git a/spawnllm/backends/claude.py b/spawnllm/backends/claude.py index e1a57d6..7747b52 100644 --- a/spawnllm/backends/claude.py +++ b/spawnllm/backends/claude.py @@ -62,21 +62,22 @@ class ClaudeCliBackend(CliBackend): install_hint: ClassVar[str] = "curl -fsSL https://claude.ai/install.sh | bash" schema_dialect: ClassVar[str | None] = "anthropic" - _isolation: ClaudeIsolation | None = None + _isolated_config_dir: str | None = None def claude_isolation(self) -> ClaudeIsolation: - """Return the process-lifetime isolation, creating and seeding its config home once. + """Return the isolation for one run: the process-lifetime config home and the env resolved now. The core's `claude_isolation_sources` op resolves the account pointer, credentials file, and Keychain service from the caller's effective config - home; this host reads those sources (falling back to the Keychain when the - credentials file is absent) and hands them to `claude_isolation_seed` for - the exact files-and-modes to write and the env to set. The files land in a - fresh temp dir removed at interpreter exit; the token only ever lives in - the env. The result is cached on the backend. + home, naming no credential source when the process already carries + `CLAUDE_CODE_OAUTH_TOKEN`; this host reads those sources (falling back to + the Keychain when the credentials file is absent) and hands them to + `claude_isolation_seed` for the exact files-and-modes to write and the env + to set. The files land in a fresh temp dir removed at interpreter exit, + created on the first call and cached on the backend; the env is resolved + on every call so a renewed Keychain token reaches the next run, and it + only ever lives in memory. """ - if self._isolation is not None: - return self._isolation sources = _core.dispatch( "claude_isolation_sources", { @@ -86,21 +87,23 @@ def claude_isolation(self) -> ClaudeIsolation: "claude_config_dir_env": os.environ.get("CLAUDE_CONFIG_DIR") or None, "claude_securestorage_config_dir_env": os.environ.get("CLAUDE_SECURESTORAGE_CONFIG_DIR"), "claude_code_custom_oauth_url_env": os.environ.get("CLAUDE_CODE_CUSTOM_OAUTH_URL"), + "claude_code_oauth_token_env": os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"), } }, ) account_json = read_file_opt(sources["account_path"]) - credentials_json = read_file_opt(sources["credentials_path"]) + credentials_json = read_file_opt(sources["credentials_path"]) if sources["credentials_path"] else None if credentials_json is None and sources["keychain_service"] is not None: credentials_json = keychain_credentials(sources["keychain_service"]) seed = _core.dispatch( "claude_isolation_seed", {"account_json": account_json, "credentials_json": credentials_json} ) - config_dir = Path(tempfile.mkdtemp(prefix="spawnllm-claude-config-")) - for file in seed["files"]: - fd = os.open(config_dir / file["name"], os.O_WRONLY | os.O_CREAT | os.O_EXCL, int(file["mode"], 8)) - with os.fdopen(fd, "w") as handle: - handle.write(file["content"]) - atexit.register(shutil.rmtree, config_dir, ignore_errors=True) - self._isolation = ClaudeIsolation(str(config_dir), seed["env"]) - return self._isolation + if self._isolated_config_dir is None: + config_dir = Path(tempfile.mkdtemp(prefix="spawnllm-claude-config-")) + for file in seed["files"]: + fd = os.open(config_dir / file["name"], os.O_WRONLY | os.O_CREAT | os.O_EXCL, int(file["mode"], 8)) + with os.fdopen(fd, "w") as handle: + handle.write(file["content"]) + atexit.register(shutil.rmtree, config_dir, ignore_errors=True) + self._isolated_config_dir = str(config_dir) + return ClaudeIsolation(self._isolated_config_dir, seed["env"]) diff --git a/tests/test_backends.py b/tests/test_backends.py index 4957899..d55e63c 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -296,6 +296,10 @@ def suffixed_keychain_service(config_dir_env: str) -> str: class TestClaudeIsolation: + @pytest.fixture(autouse=True) + def no_inherited_token(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + def test_env_isolates_and_seeds_config_dir_from_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) @@ -475,6 +479,33 @@ def test_env_keychain_miss_seeds_no_credentials(self, tmp_path: Path, monkeypatc assert "CLAUDE_CODE_OAUTH_TOKEN" not in env assert not (Path(env["CLAUDE_CONFIG_DIR"]) / ".credentials.json").exists() + def test_env_resolves_the_token_on_every_call(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".claude").mkdir() + credentials = tmp_path / ".claude" / ".credentials.json" + credentials.write_text('{"claudeAiOauth": {"accessToken": "before"}}') + backend = ClaudeCliBackend() + first = backend.env(RunSpec(prompt="hi", model="haiku")) + credentials.write_text('{"claudeAiOauth": {"accessToken": "renewed"}}') + second = backend.env(RunSpec(prompt="hi", model="haiku")) + assert (first["CLAUDE_CODE_OAUTH_TOKEN"], second["CLAUDE_CODE_OAUTH_TOKEN"]) == ("before", "renewed") + assert first["CLAUDE_CONFIG_DIR"] == second["CLAUDE_CONFIG_DIR"] + + def test_env_inherited_token_reads_no_credential_source( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "inherited") + (tmp_path / ".claude").mkdir() + (tmp_path / ".claude" / ".credentials.json").write_text("not json") + monkeypatch.setattr("spawnllm.backends.claude.sys.platform", "darwin") + monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", self.fail_on_keychain_call) + env = ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku")) + assert "CLAUDE_CODE_OAUTH_TOKEN" not in env + assert Path(env["CLAUDE_CONFIG_DIR"]).is_dir() + def test_env_non_isolated_adds_nothing(self) -> None: assert ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku", isolated=False)) == {} @@ -488,7 +519,8 @@ def test_killed_run_leaves_no_token_on_disk(self, tmp_path: Path) -> None: (tmpdir := tmp_path / "tmp").mkdir() started = tmp_path / "started" fake = bin_dir / "claude" - fake.write_text(f'#!/bin/sh\nprintf "%s\n%s" "$$" "$CLAUDE_CONFIG_DIR" > {started}\nsleep 60\n') + publish = f'printf "%s\n%s" "$$" "$CLAUDE_CONFIG_DIR" > {started}.tmp && mv {started}.tmp {started}' + fake.write_text(f"#!/bin/sh\n{publish}\nsleep 60\n") fake.chmod(0o755) env = {name: value for name, value in os.environ.items() if not name.startswith("CLAUDE_")} | { "HOME": str(home), @@ -497,11 +529,13 @@ def test_killed_run_leaves_no_token_on_disk(self, tmp_path: Path) -> None: } run = "from spawnllm import ClaudeCliBackend, RunSpec; ClaudeCliBackend().execute(RunSpec('hi', model='haiku'))" parent = subprocess.Popen([sys.executable, "-c", run], env=env) - deadline = time.monotonic() + 30 - while not started.exists() and time.monotonic() < deadline: - time.sleep(0.05) - parent.kill() - parent.wait() + try: + deadline = time.monotonic() + 30 + while not started.exists() and time.monotonic() < deadline: + time.sleep(0.05) + finally: + parent.kill() + parent.wait() pid, config_dir = started.read_text().split("\n") os.kill(int(pid), 9) assert Path(config_dir).is_relative_to(tmpdir) From 658a3641dbcc3847dc809c26462f3648102f0598 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Thu, 17 Sep 2026 13:14:56 -0700 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=F0=9F=93=9D=20give=20the=20credent?= =?UTF-8?q?ial=20fix=20its=200.13.4=20changelog=20heading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: the Security entry sat under Unreleased while this branch cuts v0.13.4 the moment it merges. Summary: retitle the entry as 0.13.4 dated today and add its compare link. Motivation: 0.13.3 shipped with its entries under Unreleased and had to be relabelled after the fact; the heading lands with the release this time. Details: the Unreleased compare link now spans from v0.13.4. Claude-Session-Id: 1dd4ee33-1b87-4c1f-af93-7a8827b07ef5 --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccfa948..320b16d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.4] - 2026-09-17 + ### Security - **Isolated `claude` runs no longer write the claude.ai token to disk.** The isolation seed copied the whole Keychain credential into a @@ -471,7 +473,8 @@ First release, published to PyPI as `spawnllm`. generation. - Click CLI: `spawnllm backends` and `spawnllm call`. -[Unreleased]: https://github.com/yasyf/spawnllm/compare/v0.13.3...HEAD +[Unreleased]: https://github.com/yasyf/spawnllm/compare/v0.13.4...HEAD +[0.13.4]: https://github.com/yasyf/spawnllm/compare/v0.13.3...v0.13.4 [0.13.3]: https://github.com/yasyf/spawnllm/compare/v0.13.2...v0.13.3 [0.13.2]: https://github.com/yasyf/spawnllm/compare/v0.13.1...v0.13.2 [0.13.1]: https://github.com/yasyf/spawnllm/compare/v0.13.0...v0.13.1