diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8b818..8eff2d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Isolated `claude` runs from the default config home find the Keychain + token again.** With `CLAUDE_CONFIG_DIR` unset, Claude Code stores the + claude.ai token under the bare `Claude Code-credentials` Keychain item and + suffixes the name with `-` only when the + variable is set, even when it names the default path. The isolation seed + looked up the suffixed name for the default home too, so on a machine with + no `~/.claude/.credentials.json` file every isolated run started without a + token and failed with `Not logged in · Please run /login`. The core now + hands the bare name to the host for the default home and the suffixed one + for a set `CLAUDE_CONFIG_DIR`. +- **The isolated config dir and its credentials file are owner-only from the + moment they exist.** The Rust host created the temp dir with the process + umask (0755 under the usual 022) and every seeded file at 0644, wrote the + token, and only then chmodded it to 0600, so another local user on a shared + tmp could read the token in that window, and a run killed inside it left + the file readable. The Python host wrote the file before its chmod too. Both + now create the dir 0700 and each file with its final mode, `O_EXCL`, before + writing a byte; the Go host already did. +- **The Keychain service name follows Claude Code 2.1.274's rule exactly.** + The core trimmed trailing slashes before hashing `CLAUDE_CONFIG_DIR`, but + Claude Code hashes the NFC form of the variable exactly as set, so `/x/` + named a different item than Claude Code wrote and the run started without + a token. The digest now covers the value as set, NFC-normalized; only the + filesystem paths joined under it are trimmed. A defined + `CLAUDE_SECURESTORAGE_CONFIG_DIR` takes over both the credentials file + location and the digest (empty means the default home and the bare item), + and a set `CLAUDE_CODE_CUSTOM_OAUTH_URL` selects the + `Claude Code-custom-oauth-credentials` items, as they do in Claude Code. + An empty `CLAUDE_CONFIG_DIR` reads the bare item, as an unset one does. + ## [0.13.2] - 2026-09-14 ### Fixed 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 c47c7d0..5acda06 100644 --- a/conformance/vectors/claude_isolation_sources/config-dir-env-darwin.json +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-darwin.json @@ -5,7 +5,9 @@ "host": { "platform": "darwin", "home": "/Users/testuser", - "claude_config_dir_env": "/Users/testuser/.acct" + "claude_config_dir_env": "/Users/testuser/.acct", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_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 new file mode 100644 index 0000000..06cc355 --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-decomposed-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "config-dir-env-decomposed-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": "/Users/testuser/résumé", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null + } + }, + "expected": { + "account_path": "/Users/testuser/résumé/.claude.json", + "credentials_path": "/Users/testuser/résumé/.credentials.json", + "keychain_service": "Claude Code-credentials-767ccf48" + } +} 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 new file mode 100644 index 0000000..ff515b3 --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-default-path-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "config-dir-env-default-path-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": "/Users/testuser/.claude", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null + } + }, + "expected": { + "account_path": "/Users/testuser/.claude/.claude.json", + "credentials_path": "/Users/testuser/.claude/.credentials.json", + "keychain_service": "Claude Code-credentials-1cc69f60" + } +} 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 new file mode 100644 index 0000000..8e681f7 --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-empty-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "config-dir-env-empty-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": "", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null + } + }, + "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/config-dir-env-linux.json b/conformance/vectors/claude_isolation_sources/config-dir-env-linux.json index 68f21c2..4a50449 100644 --- a/conformance/vectors/claude_isolation_sources/config-dir-env-linux.json +++ b/conformance/vectors/claude_isolation_sources/config-dir-env-linux.json @@ -5,7 +5,9 @@ "host": { "platform": "linux", "home": "/home/testuser", - "claude_config_dir_env": "/home/testuser/.acct" + "claude_config_dir_env": "/home/testuser/.acct", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_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 fd7c1c7..43e244b 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 @@ -5,12 +5,14 @@ "host": { "platform": "darwin", "home": "/Users/testuser", - "claude_config_dir_env": "/Users/testuser/.acct/" + "claude_config_dir_env": "/Users/testuser/.acct/", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null } }, "expected": { "account_path": "/Users/testuser/.acct/.claude.json", "credentials_path": "/Users/testuser/.acct/.credentials.json", - "keychain_service": "Claude Code-credentials-c157f0be" + "keychain_service": "Claude Code-credentials-101a62ee" } } diff --git a/conformance/vectors/claude_isolation_sources/custom-oauth-url-darwin.json b/conformance/vectors/claude_isolation_sources/custom-oauth-url-darwin.json new file mode 100644 index 0000000..219bc9e --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/custom-oauth-url-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "custom-oauth-url-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": "https://oauth.example.test" + } + }, + "expected": { + "account_path": "/Users/testuser/.claude.json", + "credentials_path": "/Users/testuser/.claude/.credentials.json", + "keychain_service": "Claude Code-custom-oauth-credentials" + } +} 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 new file mode 100644 index 0000000..2792ae8 --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/custom-oauth-url-empty-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "custom-oauth-url-empty-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": "/Users/testuser/.acct", + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": "" + } + }, + "expected": { + "account_path": "/Users/testuser/.acct/.claude.json", + "credentials_path": "/Users/testuser/.acct/.credentials.json", + "keychain_service": "Claude Code-credentials-c157f0be" + } +} diff --git a/conformance/vectors/claude_isolation_sources/default-home-darwin.json b/conformance/vectors/claude_isolation_sources/default-home-darwin.json index 9caae05..c9d6057 100644 --- a/conformance/vectors/claude_isolation_sources/default-home-darwin.json +++ b/conformance/vectors/claude_isolation_sources/default-home-darwin.json @@ -5,12 +5,14 @@ "host": { "platform": "darwin", "home": "/Users/testuser", - "claude_config_dir_env": null + "claude_config_dir_env": null, + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null } }, "expected": { "account_path": "/Users/testuser/.claude.json", "credentials_path": "/Users/testuser/.claude/.credentials.json", - "keychain_service": "Claude Code-credentials-1cc69f60" + "keychain_service": "Claude Code-credentials" } } diff --git a/conformance/vectors/claude_isolation_sources/default-home-linux.json b/conformance/vectors/claude_isolation_sources/default-home-linux.json index 2296f57..e06aed3 100644 --- a/conformance/vectors/claude_isolation_sources/default-home-linux.json +++ b/conformance/vectors/claude_isolation_sources/default-home-linux.json @@ -5,7 +5,9 @@ "host": { "platform": "linux", "home": "/home/testuser", - "claude_config_dir_env": null + "claude_config_dir_env": null, + "claude_securestorage_config_dir_env": null, + "claude_code_custom_oauth_url_env": null } }, "expected": { diff --git a/conformance/vectors/claude_isolation_sources/securestorage-env-darwin.json b/conformance/vectors/claude_isolation_sources/securestorage-env-darwin.json new file mode 100644 index 0000000..92a2c9a --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/securestorage-env-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "securestorage-env-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": null, + "claude_securestorage_config_dir_env": "/Users/testuser/.secure", + "claude_code_custom_oauth_url_env": null + } + }, + "expected": { + "account_path": "/Users/testuser/.claude.json", + "credentials_path": "/Users/testuser/.secure/.credentials.json", + "keychain_service": "Claude Code-credentials-205e9e3d" + } +} 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 new file mode 100644 index 0000000..b021c7b --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/securestorage-env-empty-over-config-dir-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "securestorage-env-empty-over-config-dir-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "home": "/Users/testuser", + "claude_config_dir_env": "/Users/testuser/.acct", + "claude_securestorage_config_dir_env": "", + "claude_code_custom_oauth_url_env": null + } + }, + "expected": { + "account_path": "/Users/testuser/.acct/.claude.json", + "credentials_path": "/Users/testuser/.claude/.credentials.json", + "keychain_service": "Claude Code-credentials" + } +} 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 new file mode 100644 index 0000000..426dad6 --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/securestorage-env-over-config-dir-darwin.json @@ -0,0 +1,18 @@ +{ + "name": "securestorage-env-over-config-dir-darwin", + "op": "claude_isolation_sources", + "input": { + "host": { + "platform": "darwin", + "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 + } + }, + "expected": { + "account_path": "/Users/testuser/.acct/.claude.json", + "credentials_path": "/Users/testuser/.secure/.credentials.json", + "keychain_service": "Claude Code-credentials-ea269d8c" + } +} diff --git a/go/coreops.go b/go/coreops.go index ecd9bfd..6a1688a 100644 --- a/go/coreops.go +++ b/go/coreops.go @@ -3,6 +3,7 @@ package spawnllm import ( "encoding/json" "fmt" + "os" "github.com/yasyf/spawnllm/go/internal/core" ) @@ -197,10 +198,22 @@ func coreAuthProbes(provider Provider) (authProbes, error) { } func coreIsolationSources() (isolationSources, error) { - host := map[string]any{"platform": platform(), "home": home(), "claude_config_dir_env": nil} + host := map[string]any{ + "platform": platform(), + "home": home(), + "claude_config_dir_env": nil, + "claude_securestorage_config_dir_env": nil, + "claude_code_custom_oauth_url_env": nil, + } if dir := configDirEnv(); dir != "" { host["claude_config_dir_env"] = dir } + if dir, defined := os.LookupEnv("CLAUDE_SECURESTORAGE_CONFIG_DIR"); defined { + host["claude_securestorage_config_dir_env"] = dir + } + if url, defined := os.LookupEnv("CLAUDE_CODE_CUSTOM_OAUTH_URL"); defined { + host["claude_code_custom_oauth_url_env"] = url + } 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 c416823..fa31d04 100644 --- a/go/exec_test.go +++ b/go/exec_test.go @@ -2,10 +2,13 @@ package spawnllm import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -26,6 +29,8 @@ type claudeOutput struct { Seeded bool `json:"seeded"` AccountHasMCP bool `json:"account_has_mcp"` CredsPresent bool `json:"creds_present"` + ConfigDirMode string `json:"config_dir_mode"` + CredsMode string `json:"creds_mode"` } func TestClaudeStdinAndStdoutFile(t *testing.T) { @@ -312,11 +317,155 @@ func TestClaudeIsolationSeeding(t *testing.T) { if !out.CredsPresent { t.Fatal("isolated config dir was not seeded with .credentials.json") } + 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 _, err := os.Stat(out.ConfigDir); !os.IsNotExist(err) { t.Fatalf("isolated config dir was not cleaned up: stat err = %v", err) } } +func suffixedKeychainService(configDirEnv string) string { + digest := sha256.Sum256([]byte(configDirEnv)) + return "Claude Code-credentials-" + hex.EncodeToString(digest[:])[:8] +} + +func keychainSeededRun(t *testing.T, service string) (claudeOutput, string) { + t.Helper() + if runtime.GOOS != "darwin" { + t.Skip("the Keychain fallback runs only on darwin") + } + withFakeBin(t) + argvOut := filepath.Join(t.TempDir(), "argv") + t.Setenv("FAKE_SECURITY_ARGV_OUT", argvOut) + t.Setenv("FAKE_KEYCHAIN_SERVICE", service) + t.Setenv("FAKE_KEYCHAIN_CREDENTIAL", `{"claudeAiOauth":{"accessToken":"kc-tok"}}`) + + 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) + } + argv, err := os.ReadFile(argvOut) + if err != nil { + t.Fatalf("fake security recorded no argv: %v", err) + } + return out, string(argv) +} + +func writeAccountPointer(t *testing.T, dir string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, ".claude.json"), []byte(`{"oauthAccount":{"accountUuid":"a"}}`), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestClaudeIsolationKeychain(t *testing.T) { + cases := []struct { + name string + env func(home, acct string) map[string]string + service func(home, acct string) string + }{ + { + name: "unset env reads the bare item", + env: func(string, string) map[string]string { return nil }, + service: func(string, string) string { return "Claude Code-credentials" }, + }, + { + name: "set env reads the suffixed item", + env: func(_, acct string) map[string]string { return map[string]string{"CLAUDE_CONFIG_DIR": acct} }, + service: func(_, acct string) string { return suffixedKeychainService(acct) }, + }, + { + name: "trailing slash is hashed as set", + env: func(_, acct string) map[string]string { return map[string]string{"CLAUDE_CONFIG_DIR": acct + "/"} }, + service: func(_, acct string) string { return suffixedKeychainService(acct + "/") }, + }, + { + name: "env naming the default path is still suffixed", + env: func(home, _ string) map[string]string { + return map[string]string{"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude")} + }, + service: func(home, _ string) string { return suffixedKeychainService(filepath.Join(home, ".claude")) }, + }, + { + name: "empty securestorage env reads the bare item over config dir env", + env: func(_, acct string) map[string]string { + return map[string]string{"CLAUDE_CONFIG_DIR": acct, "CLAUDE_SECURESTORAGE_CONFIG_DIR": ""} + }, + service: func(string, string) string { return "Claude Code-credentials" }, + }, + { + name: "securestorage env is hashed over config dir env", + env: func(home, acct string) map[string]string { + return map[string]string{"CLAUDE_CONFIG_DIR": acct, "CLAUDE_SECURESTORAGE_CONFIG_DIR": home + "/secure"} + }, + service: func(home, _ string) string { return suffixedKeychainService(home + "/secure") }, + }, + { + name: "custom oauth url names the custom-oauth item", + env: func(_, acct string) map[string]string { + return map[string]string{"CLAUDE_CONFIG_DIR": acct, "CLAUDE_CODE_CUSTOM_OAUTH_URL": "https://oauth.example.test"} + }, + service: func(_, acct string) string { + return strings.Replace(suffixedKeychainService(acct), "Claude Code-credentials", "Claude Code-custom-oauth-credentials", 1) + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + acct := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + 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) + } + } + for name, value := range tc.env(home, acct) { + t.Setenv(name, value) + } + service := tc.service(home, acct) + + out, argv := keychainSeededRun(t, service) + + 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) + } + }) + } +} + +func TestClaudeIsolationKeychainMissSeedsNoCredentials(t *testing.T) { + acct := t.TempDir() + writeAccountPointer(t, acct) + t.Setenv("CLAUDE_CONFIG_DIR", acct) + + out, argv := keychainSeededRun(t, "Claude Code-credentials-someone-else") + + 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") + } +} + type echoResult struct { Echo string `json:"echo"` } diff --git a/go/testdata/bin/claude b/go/testdata/bin/claude index 96341d2..968882d 100755 --- a/go/testdata/bin/claude +++ b/go/testdata/bin/claude @@ -18,7 +18,13 @@ config_dir="${CLAUDE_CONFIG_DIR:-}" seeded=false account_has_mcp=false creds_present=false +config_dir_mode="" +creds_mode="" +mode_of() { + ls -ld "$1" | cut -c1-10 +} if [ -n "$config_dir" ]; then + config_dir_mode="$(mode_of "$config_dir")" if [ -f "$config_dir/.claude.json" ]; then seeded=true if grep -q mcpServers "$config_dir/.claude.json"; then @@ -27,6 +33,7 @@ if [ -n "$config_dir" ]; then fi if [ -f "$config_dir/.credentials.json" ]; then creds_present=true + creds_mode="$(mode_of "$config_dir/.credentials.json")" fi fi @@ -43,5 +50,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}\n' \ - "$prompt" "$stdout_regular" "$config_dir" "$seeded" "$account_has_mcp" "$creds_present" +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" diff --git a/go/testdata/bin/security b/go/testdata/bin/security index 864e0be..7faebf7 100755 --- a/go/testdata/bin/security +++ b/go/testdata/bin/security @@ -1,20 +1,19 @@ #!/bin/sh -# Fake macOS `security` for spawnllm integration tests. Answers -# find-generic-password: -w prints FAKE_KEYCHAIN_CREDENTIAL (empty exits 1, a -# keychain miss), and the account-existence probe exits per FAKE_KEYCHAIN_EXISTS. -want_w=false -for arg in "$@"; do - if [ "$arg" = "-w" ]; then - want_w=true - fi -done +# Fake macOS `security` for spawnllm integration tests. Records its argv to +# FAKE_SECURITY_ARGV_OUT. A password read (`find-generic-password -s -w`) +# prints FAKE_KEYCHAIN_CREDENTIAL only for the service FAKE_KEYCHAIN_SERVICE names +# (anything else exits 44, a keychain miss); the account-existence probe exits per +# FAKE_KEYCHAIN_EXISTS. +if [ -n "$FAKE_SECURITY_ARGV_OUT" ]; then + printf '%s\n' "$@" >"$FAKE_SECURITY_ARGV_OUT" +fi -if [ "$want_w" = true ]; then - if [ -n "$FAKE_KEYCHAIN_CREDENTIAL" ]; then +if [ "$#" -eq 4 ] && [ "$1" = "find-generic-password" ] && [ "$2" = "-s" ] && [ "$4" = "-w" ]; then + if [ -n "$FAKE_KEYCHAIN_CREDENTIAL" ] && [ "$3" = "$FAKE_KEYCHAIN_SERVICE" ]; then printf '%s' "$FAKE_KEYCHAIN_CREDENTIAL" exit 0 fi - exit 1 + exit 44 fi if [ "${FAKE_KEYCHAIN_EXISTS:-0}" = "1" ]; then diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 02673c9..95e944d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -959,6 +959,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "sha2", "spawnllm-core", "tempfile", "thiserror", @@ -973,6 +974,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "unicode-normalization", ] [[package]] @@ -1202,6 +1204,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/rust/conformance-gen/src/cases.rs b/rust/conformance-gen/src/cases.rs index 81657f4..8460f07 100644 --- a/rust/conformance-gen/src/cases.rs +++ b/rust/conformance-gen/src/cases.rs @@ -1024,20 +1024,39 @@ fn capabilities_cases() -> Vec { }] } -fn iso_sources_case( - name: &str, - platform: &str, - home: &str, - claude_config_dir_env: Option<&str>, -) -> Case { +struct IsoHost { + platform: &'static str, + home: &'static str, + config_dir_env: Option<&'static str>, + securestorage_config_dir_env: Option<&'static str>, + custom_oauth_url_env: Option<&'static str>, +} + +const ISO_DARWIN: IsoHost = IsoHost { + platform: "darwin", + home: HOME_DARWIN, + config_dir_env: None, + securestorage_config_dir_env: None, + custom_oauth_url_env: None, +}; + +const ISO_LINUX: IsoHost = IsoHost { + platform: "linux", + home: HOME_LINUX, + ..ISO_DARWIN +}; + +fn iso_sources_case(name: &str, host: IsoHost) -> Case { Case { op: "claude_isolation_sources", name: name.to_owned(), input: json!({ "host": { - "platform": platform, - "home": home, - "claude_config_dir_env": claude_config_dir_env, + "platform": host.platform, + "home": host.home, + "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, } }), } @@ -1045,25 +1064,87 @@ fn iso_sources_case( fn iso_sources_cases() -> Vec { vec![ - iso_sources_case("default-home-darwin", "darwin", HOME_DARWIN, None), - iso_sources_case("default-home-linux", "linux", HOME_LINUX, None), + iso_sources_case("default-home-darwin", ISO_DARWIN), + iso_sources_case("default-home-linux", ISO_LINUX), iso_sources_case( "config-dir-env-darwin", - "darwin", - HOME_DARWIN, - Some("/Users/testuser/.acct"), + IsoHost { + config_dir_env: Some("/Users/testuser/.acct"), + ..ISO_DARWIN + }, ), iso_sources_case( "config-dir-env-trailing-slash-darwin", - "darwin", - HOME_DARWIN, - Some("/Users/testuser/.acct/"), + IsoHost { + config_dir_env: Some("/Users/testuser/.acct/"), + ..ISO_DARWIN + }, ), iso_sources_case( "config-dir-env-linux", - "linux", - HOME_LINUX, - Some("/home/testuser/.acct"), + IsoHost { + config_dir_env: Some("/home/testuser/.acct"), + ..ISO_LINUX + }, + ), + iso_sources_case( + "config-dir-env-empty-darwin", + IsoHost { + config_dir_env: Some(""), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "config-dir-env-default-path-darwin", + IsoHost { + config_dir_env: Some("/Users/testuser/.claude"), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "config-dir-env-decomposed-darwin", + IsoHost { + config_dir_env: Some("/Users/testuser/re\u{0301}sume\u{0301}"), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "securestorage-env-darwin", + IsoHost { + securestorage_config_dir_env: Some("/Users/testuser/.secure"), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "securestorage-env-empty-over-config-dir-darwin", + IsoHost { + config_dir_env: Some("/Users/testuser/.acct"), + securestorage_config_dir_env: Some(""), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "securestorage-env-over-config-dir-darwin", + IsoHost { + config_dir_env: Some("/Users/testuser/.acct"), + securestorage_config_dir_env: Some("/Users/testuser/.secure/"), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "custom-oauth-url-darwin", + IsoHost { + custom_oauth_url_env: Some("https://oauth.example.test"), + ..ISO_DARWIN + }, + ), + iso_sources_case( + "custom-oauth-url-empty-darwin", + IsoHost { + config_dir_env: Some("/Users/testuser/.acct"), + custom_oauth_url_env: Some(""), + ..ISO_DARWIN + }, ), ] } diff --git a/rust/spawnllm-core/Cargo.toml b/rust/spawnllm-core/Cargo.toml index 9db0f82..3e20ab6 100644 --- a/rust/spawnllm-core/Cargo.toml +++ b/rust/spawnllm-core/Cargo.toml @@ -12,3 +12,4 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] } regex-lite = "0.1" sha2 = "0.10" +unicode-normalization = "0.1" diff --git a/rust/spawnllm-core/src/isolate.rs b/rust/spawnllm-core/src/isolate.rs index b27b0ba..d6fe7bb 100644 --- a/rust/spawnllm-core/src/isolate.rs +++ b/rust/spawnllm-core/src/isolate.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::ser::Formatter; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; +use unicode_normalization::UnicodeNormalization; use crate::{OpError, OpResult, from_input, unimplemented}; @@ -17,6 +18,10 @@ struct IsolationHost { platform: String, home: String, claude_config_dir_env: Option, + #[serde(default)] + claude_securestorage_config_dir_env: Option, + #[serde(default)] + claude_code_custom_oauth_url_env: Option, } #[derive(Debug, Serialize)] @@ -77,25 +82,55 @@ impl Formatter for PythonFormatter { } } +fn is_set(value: &Option) -> bool { + value.as_deref().is_some_and(|value| !value.is_empty()) +} + +fn config_dir_digest(config_dir_env: &str) -> String { + let digest = format!( + "{:x}", + Sha256::digest(config_dir_env.nfc().collect::().as_bytes()) + ); + format!("-{}", &digest[..8]) +} + +// Mirrors Claude Code 2.1.274's lb()/mI(): a defined CLAUDE_SECURESTORAGE_CONFIG_DIR +// 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 (account_path, config_home) = match host.claude_config_dir_env { - Some(config_home) => { - let config_home = config_home.trim_end_matches('/').to_owned(); - (format!("{config_home}/.claude.json"), config_home) + 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() => { + let config_home = config_dir_env.trim_end_matches('/'); + ( + format!("{config_home}/.claude.json"), + config_home.to_owned(), + ) } + _ => (format!("{}/.claude.json", host.home), default_home.clone()), + }; + let (credentials_home, hashed_dir) = match &host.claude_securestorage_config_dir_env { + Some(dir) if dir.is_empty() => (default_home, None), + Some(dir) => (dir.trim_end_matches('/').to_owned(), Some(dir.as_str())), None => ( - format!("{}/.claude.json", host.home), - format!("{}/.claude", host.home), + config_home, + host.claude_config_dir_env + .as_deref() + .filter(|dir| !dir.is_empty()), ), }; + let oauth_file_suffix = if is_set(&host.claude_code_custom_oauth_url_env) { + "-custom-oauth" + } else { + "" + }; let keychain_service = (host.platform == "darwin").then(|| { - let digest = format!("{:x}", Sha256::digest(config_home.as_bytes())); - format!("Claude Code-credentials-{}", &digest[..8]) + 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!("{config_home}/.credentials.json"), + credentials_path: format!("{credentials_home}/.credentials.json"), keychain_service, } } diff --git a/rust/spawnllm/Cargo.toml b/rust/spawnllm/Cargo.toml index fc03f95..83e1120 100644 --- a/rust/spawnllm/Cargo.toml +++ b/rust/spawnllm/Cargo.toml @@ -30,6 +30,7 @@ tokio = { version = "1", features = ["process", "time", "rt", "rt-multi-thread", serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1" +sha2 = "0.10" tempfile = "3" [package.metadata.docs.rs] diff --git a/rust/spawnllm/src/isolate.rs b/rust/spawnllm/src/isolate.rs index 0b04d89..f7bb791 100644 --- a/rust/spawnllm/src/isolate.rs +++ b/rust/spawnllm/src/isolate.rs @@ -1,3 +1,4 @@ +use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::Path; #[cfg(any(target_os = "macos", test))] @@ -39,6 +40,8 @@ pub(crate) async fn seed_isolation() -> Result { "platform": platform(), "home": home(), "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(), } }), )?; @@ -56,19 +59,26 @@ pub(crate) async fn seed_isolation() -> Result { json!({ "account_json": account_json, "credentials_json": credentials_json }), )?; - let dir = tempfile::Builder::new() - .prefix("spawnllm-claude-config-") - .tempdir()?; + let dir = private_tempdir()?; for file in &seed.files { - let path = dir.path().join(&file.name); - let mut handle = std::fs::File::create(&path)?; + let mut handle = create_with_mode(&dir.path().join(&file.name), &file.mode)?; handle.write_all(file.content.as_bytes())?; handle.flush()?; - set_mode(&path, &file.mode)?; } Ok(dir) } +fn private_tempdir() -> std::io::Result { + let mut builder = tempfile::Builder::new(); + builder.prefix("spawnllm-claude-config-"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + builder.permissions(std::fs::Permissions::from_mode(0o700)); + } + builder.tempdir() +} + async fn keychain_credentials(service: &str) -> Option { #[cfg(target_os = "macos")] { @@ -100,16 +110,20 @@ async fn timed_command_output( } #[cfg(unix)] -fn set_mode(path: &Path, mode: &str) -> std::io::Result<()> { - use std::os::unix::fs::PermissionsExt; +fn create_with_mode(path: &Path, mode: &str) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; let bits = u32::from_str_radix(mode, 8).expect("core emits octal file modes"); - std::fs::set_permissions(path, std::fs::Permissions::from_mode(bits)) + OpenOptions::new() + .write(true) + .create_new(true) + .mode(bits) + .open(path) } #[cfg(not(unix))] -fn set_mode(_path: &Path, _mode: &str) -> std::io::Result<()> { - Ok(()) +fn create_with_mode(path: &Path, _mode: &str) -> std::io::Result { + OpenOptions::new().write(true).create_new(true).open(path) } #[cfg(test)] @@ -118,6 +132,35 @@ mod tests { use super::*; + #[cfg(unix)] + #[test] + fn private_tempdir_is_owner_only_at_creation() { + use std::os::unix::fs::PermissionsExt; + + let dir = private_tempdir().unwrap(); + + assert_eq!( + dir.path().metadata().unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + + #[cfg(unix)] + #[test] + fn create_with_mode_applies_the_mode_before_any_byte_is_written() { + use std::os::unix::fs::PermissionsExt; + + let dir = private_tempdir().unwrap(); + let path = dir.path().join(".credentials.json"); + let mut handle = create_with_mode(&path, "0600").unwrap(); + let created = path.metadata().unwrap(); + + assert_eq!(created.len(), 0); + assert_eq!(created.permissions().mode() & 0o777, 0o600); + handle.write_all(b"{}").unwrap(); + assert!(create_with_mode(&path, "0600").is_err()); + } + #[tokio::test] async fn timed_command_output_returns_none_on_spawn_failure() { let mut command = tokio::process::Command::new("spawnllm-command-that-does-not-exist"); diff --git a/rust/spawnllm/tests/common/mod.rs b/rust/spawnllm/tests/common/mod.rs index 6fa3c38..7128964 100644 --- a/rust/spawnllm/tests/common/mod.rs +++ b/rust/spawnllm/tests/common/mod.rs @@ -42,6 +42,7 @@ if [ -n "$SPAWNLLM_FAKE_MARKER" ]; then 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_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_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 @@ -81,10 +82,11 @@ fi "#; const SECURITY_FAKE: &str = r#"#!/bin/sh -for a in "$@"; do - if [ "$a" = "-w" ]; then printf 'keychain-token-xyz'; exit 0; fi -done -exit 1 +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 +fi +exit 44 "#; /// Materialize the fake CLIs once and prepend their dir to `PATH`; returns the dir. diff --git a/rust/spawnllm/tests/isolation.rs b/rust/spawnllm/tests/isolation.rs index c3a0c9b..faa5c91 100644 --- a/rust/spawnllm/tests/isolation.rs +++ b/rust/spawnllm/tests/isolation.rs @@ -47,13 +47,16 @@ async fn isolation_seeds_stripped_account_and_credentials_from_files() { let cred_out = tempfile::NamedTempFile::new().unwrap(); let account_out = tempfile::NamedTempFile::new().unwrap(); + let modes_out = tempfile::NamedTempFile::new().unwrap(); let cred_path = cred_out.path().to_str().unwrap().to_owned(); let account_path = account_out.path().to_str().unwrap().to_owned(); + let modes_path = modes_out.path().to_str().unwrap().to_owned(); set_config_dir(source.path()); let spec = RunSpec::new("hi", "haiku").env(env(&[ ("SPAWNLLM_FAKE_CRED_OUT", &cred_path), ("SPAWNLLM_FAKE_ACCOUNT_OUT", &account_path), + ("SPAWNLLM_FAKE_MODES_OUT", &modes_path), ])); let response = spawnllm::run_on(&Backend::Claude, spec).await; clear_config_dir(); @@ -63,6 +66,11 @@ async fn isolation_seeds_stripped_account_and_credentials_from_files() { std::fs::read_to_string(&cred_path).unwrap(), r#"{"token": "abc"}"# ); + assert_eq!( + std::fs::read_to_string(&modes_path).unwrap(), + "drwx------\n-rw-------\n", + "config dir mode then credentials file mode" + ); let account: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&account_path).unwrap()).unwrap(); assert!( @@ -113,10 +121,66 @@ async fn empty_claude_config_dir_uses_the_default_home() { ); } +#[cfg(target_os = "macos")] +fn suffixed_keychain_service(config_dir_env: &str) -> String { + use sha2::{Digest, Sha256}; + + let digest = format!("{:x}", Sha256::digest(config_dir_env.as_bytes())); + format!("Claude Code-credentials-{}", &digest[..8]) +} + +#[cfg(target_os = "macos")] +const HOST_KEYCHAIN_VARS: [&str; 3] = [ + "CLAUDE_CONFIG_DIR", + "CLAUDE_SECURESTORAGE_CONFIG_DIR", + "CLAUDE_CODE_CUSTOM_OAUTH_URL", +]; + +#[cfg(target_os = "macos")] +async fn keychain_seeded_run( + host_env: &[(&str, &str)], + keychain_service: Option<&str>, +) -> (String, String) { + let cred_out = tempfile::NamedTempFile::new().unwrap(); + let argv_out = tempfile::NamedTempFile::new().unwrap(); + let cred_path = cred_out.path().to_str().unwrap().to_owned(); + let argv_path = argv_out.path().to_str().unwrap().to_owned(); + + // SAFETY: gated by ENV_LOCK, held by the caller; see set_config_dir. + unsafe { + for var in HOST_KEYCHAIN_VARS { + std::env::remove_var(var); + } + for (var, value) in host_env { + std::env::set_var(var, value); + } + match keychain_service { + Some(service) => std::env::set_var("SPAWNLLM_FAKE_KEYCHAIN_SERVICE", service), + None => std::env::remove_var("SPAWNLLM_FAKE_KEYCHAIN_SERVICE"), + } + 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 { + for var in HOST_KEYCHAIN_VARS { + std::env::remove_var(var); + } + std::env::remove_var("SPAWNLLM_FAKE_KEYCHAIN_SERVICE"); + std::env::remove_var("SPAWNLLM_FAKE_SECURITY_ARGV_OUT"); + } + + response.outcome.expect("isolated claude run succeeds"); + ( + std::fs::read_to_string(&cred_path).unwrap(), + std::fs::read_to_string(&argv_path).unwrap(), + ) +} + #[cfg(target_os = "macos")] #[allow(clippy::await_holding_lock)] #[tokio::test] -async fn isolation_falls_back_to_the_keychain_for_credentials() { +async fn config_dir_env_falls_back_to_the_suffixed_keychain_item() { common::fixtures(); let _guard = common::ENV_LOCK .lock() @@ -124,18 +188,179 @@ async fn isolation_falls_back_to_the_keychain_for_credentials() { let source = tempfile::tempdir().unwrap(); std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = source.path().to_str().unwrap(); + let service = suffixed_keychain_service(config_dir_env); - let cred_out = tempfile::NamedTempFile::new().unwrap(); - let cred_path = cred_out.path().to_str().unwrap().to_owned(); + let (credentials, argv) = + keychain_seeded_run(&[("CLAUDE_CONFIG_DIR", config_dir_env)], Some(&service)).await; - set_config_dir(source.path()); - let spec = RunSpec::new("hi", "haiku").env(env(&[("SPAWNLLM_FAKE_CRED_OUT", &cred_path)])); - let response = spawnllm::run_on(&Backend::Claude, spec).await; - clear_config_dir(); + assert_eq!(credentials, "keychain-token-xyz"); + assert_eq!(argv, format!("find-generic-password\n-s\n{service}\n-w\n")); +} - response.outcome.expect("isolated claude run succeeds"); +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn empty_securestorage_env_reads_the_bare_item_over_config_dir_env() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let source = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = source.path().to_str().unwrap(); + + let (credentials, argv) = keychain_seeded_run( + &[ + ("CLAUDE_CONFIG_DIR", config_dir_env), + ("CLAUDE_SECURESTORAGE_CONFIG_DIR", ""), + ], + Some("Claude Code-credentials"), + ) + .await; + + assert_eq!(credentials, "keychain-token-xyz"); assert_eq!( - std::fs::read_to_string(&cred_path).unwrap(), - "keychain-token-xyz" + argv, + "find-generic-password\n-s\nClaude Code-credentials\n-w\n" + ); +} + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn securestorage_env_names_the_hashed_item_over_config_dir_env() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let source = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = source.path().to_str().unwrap(); + let secure = tempfile::tempdir().unwrap(); + let securestorage_env = secure.path().to_str().unwrap(); + let service = suffixed_keychain_service(securestorage_env); + + let (credentials, argv) = keychain_seeded_run( + &[ + ("CLAUDE_CONFIG_DIR", config_dir_env), + ("CLAUDE_SECURESTORAGE_CONFIG_DIR", securestorage_env), + ], + Some(&service), + ) + .await; + + assert_eq!(credentials, "keychain-token-xyz"); + assert_eq!(argv, format!("find-generic-password\n-s\n{service}\n-w\n")); +} + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn custom_oauth_url_env_names_the_custom_oauth_item() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let source = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = source.path().to_str().unwrap(); + let service = suffixed_keychain_service(config_dir_env).replace( + "Claude Code-credentials", + "Claude Code-custom-oauth-credentials", + ); + + let (credentials, argv) = keychain_seeded_run( + &[ + ("CLAUDE_CONFIG_DIR", config_dir_env), + ("CLAUDE_CODE_CUSTOM_OAUTH_URL", "https://oauth.example.test"), + ], + Some(&service), + ) + .await; + + assert_eq!(credentials, "keychain-token-xyz"); + assert_eq!(argv, format!("find-generic-password\n-s\n{service}\n-w\n")); +} + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn trailing_slash_config_dir_env_is_hashed_as_set() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let source = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = format!("{}/", source.path().to_str().unwrap()); + let service = suffixed_keychain_service(&config_dir_env); + assert_ne!( + service, + suffixed_keychain_service(source.path().to_str().unwrap()) + ); + + let (credentials, argv) = + keychain_seeded_run(&[("CLAUDE_CONFIG_DIR", &config_dir_env)], Some(&service)).await; + + assert_eq!(credentials, "keychain-token-xyz"); + assert_eq!(argv, format!("find-generic-password\n-s\n{service}\n-w\n")); +} + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn default_home_falls_back_to_the_bare_keychain_item() { + 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(); + let original_home = std::env::var_os("HOME"); + unsafe { std::env::set_var("HOME", home.path()) }; + + let (credentials, argv) = keychain_seeded_run(&[], Some("Claude Code-credentials")).await; + + match original_home { + Some(value) => unsafe { std::env::set_var("HOME", value) }, + None => unsafe { std::env::remove_var("HOME") }, + } + assert_eq!(credentials, "keychain-token-xyz"); + assert_eq!( + argv, + "find-generic-password\n-s\nClaude Code-credentials\n-w\n" + ); +} + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn keychain_miss_seeds_no_credentials() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let source = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + let config_dir_env = source.path().to_str().unwrap(); + + let (credentials, argv) = + keychain_seeded_run(&[("CLAUDE_CONFIG_DIR", config_dir_env)], None).await; + + assert_eq!(credentials, ""); + assert_eq!( + argv, + format!( + "find-generic-password\n-s\n{}\n-w\n", + suffixed_keychain_service(config_dir_env) + ) ); } diff --git a/spawnllm/backends/claude.py b/spawnllm/backends/claude.py index 80c118e..3e22cd7 100644 --- a/spawnllm/backends/claude.py +++ b/spawnllm/backends/claude.py @@ -82,6 +82,8 @@ def claude_isolation(self) -> str: "platform": sys.platform, "home": str(Path.home()), "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"), } }, ) @@ -94,9 +96,9 @@ def claude_isolation(self) -> str: ) config_dir = Path(tempfile.mkdtemp(prefix="spawnllm-claude-config-")) for file in seed["files"]: - path = config_dir / file["name"] - path.write_text(file["content"]) - path.chmod(int(file["mode"], 8)) + 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 self._isolated_config_dir diff --git a/tests/test_backends.py b/tests/test_backends.py index f105bbd..990cc82 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -3,6 +3,7 @@ import dataclasses import hashlib import json +import os from collections.abc import Callable from pathlib import Path @@ -287,6 +288,10 @@ def fake_capture_cli(argv: list[str], **kwargs: object) -> RunResult: assert plan_calls == 2 +def suffixed_keychain_service(config_dir_env: str) -> str: + return f"Claude Code-credentials-{hashlib.sha256(config_dir_env.encode()).hexdigest()[:8]}" + + class TestClaudeIsolation: 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) @@ -296,16 +301,45 @@ def test_env_isolates_and_seeds_config_dir_from_home(self, tmp_path: Path, monke ) (tmp_path / ".claude").mkdir() (tmp_path / ".claude" / ".credentials.json").write_text('{"claudeAiOauth": {"accessToken": "tok"}}') + monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", self.fail_on_keychain_call) backend = ClaudeCliBackend() env = backend.env(RunSpec(prompt="hi", model="haiku")) config_dir = Path(env["CLAUDE_CONFIG_DIR"]) 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"}} - assert json.loads((config_dir / ".credentials.json").read_text()) == {"claudeAiOauth": {"accessToken": "tok"}} + credentials = config_dir / ".credentials.json" + assert json.loads(credentials.read_text()) == {"claudeAiOauth": {"accessToken": "tok"}} + assert credentials.stat().st_mode & 0o777 == 0o600 + + @staticmethod + def fail_on_keychain_call(argv: list[str], **kwargs: object) -> object: + raise AssertionError(f"unexpected Keychain call: {argv}") + + def test_env_seeds_each_file_with_its_mode_before_writing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "a"}})) + (tmp_path / ".claude").mkdir() + (tmp_path / ".claude" / ".credentials.json").write_text('{"claudeAiOauth": {"accessToken": "tok"}}') + real_open = os.open + created: list[tuple[str, int, int]] = [] + + def observing_open(path: str | os.PathLike[str], flags: int, mode: int = 0o777) -> int: + fd = real_open(path, flags, mode) + stat = os.stat(fd) + created.append((Path(path).name, stat.st_mode & 0o777, stat.st_size)) + return fd + + 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)] 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")) @@ -319,7 +353,30 @@ def test_env_seeds_from_claude_config_dir_over_home(self, tmp_path: Path, monkey "claudeAiOauth": {"accessToken": "acct-tok"} } - def test_env_falls_back_to_keychain_for_credentials(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_env_default_home_falls_back_to_the_bare_keychain_item( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "c"}})) + (tmp_path / ".claude").mkdir() + monkeypatch.setattr("spawnllm.backends.claude.sys.platform", "darwin") + calls: list[list[str]] = [] + + def fake_run(argv: list[str], **kwargs: object) -> object: + calls.append(argv) + 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"]) + 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 + + def test_env_config_dir_falls_back_to_the_suffixed_keychain_item( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: (account_home := tmp_path / "acct").mkdir() monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(account_home)) (account_home / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "c"}})) @@ -332,13 +389,83 @@ def fake_run(argv: list[str], **kwargs: object) -> object: monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", fake_run) config_dir = Path(ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku"))["CLAUDE_CONFIG_DIR"]) - # The service name hashes the effective home path, matching the CLI's Keychain item. - digest = hashlib.sha256(str(account_home).encode()).hexdigest()[:8] - assert calls == [["security", "find-generic-password", "-s", f"Claude Code-credentials-{digest}", "-w"]] + 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 + @pytest.mark.parametrize( + "config_dir_env, expected_service", + [ + (lambda home, acct: {"CLAUDE_CONFIG_DIR": ""}, lambda home, acct: "Claude Code-credentials"), + ( + lambda home, acct: {"CLAUDE_CONFIG_DIR": f"{acct}/"}, + lambda home, acct: suffixed_keychain_service(f"{acct}/"), + ), + ( + lambda home, acct: {"CLAUDE_CONFIG_DIR": f"{home}/.claude"}, + lambda home, acct: suffixed_keychain_service(f"{home}/.claude"), + ), + ( + lambda home, acct: {"CLAUDE_CONFIG_DIR": acct, "CLAUDE_SECURESTORAGE_CONFIG_DIR": ""}, + lambda home, acct: "Claude Code-credentials", + ), + ( + lambda home, acct: {"CLAUDE_CONFIG_DIR": acct, "CLAUDE_SECURESTORAGE_CONFIG_DIR": f"{home}/secure"}, + lambda home, acct: suffixed_keychain_service(f"{home}/secure"), + ), + ( + lambda home, acct: { + "CLAUDE_CONFIG_DIR": acct, + "CLAUDE_CODE_CUSTOM_OAUTH_URL": "https://oauth.example.test", + }, + lambda home, acct: suffixed_keychain_service(acct).replace( + "Claude Code-credentials", "Claude Code-custom-oauth-credentials" + ), + ), + ], + ids=[ + "empty-env-reads-the-bare-item", + "trailing-slash-hashed-as-set", + "default-path-env-still-suffixed", + "empty-securestorage-env-reads-the-bare-item", + "securestorage-env-hashed-over-config-dir", + "custom-oauth-url-names-the-custom-oauth-item", + ], + ) + def test_env_keychain_service_follows_the_env_value_as_set( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + config_dir_env: Callable[[str, str], dict[str, str]], + expected_service: Callable[[str, str], str], + ) -> None: + (home := tmp_path / "home").mkdir() + (home / ".claude").mkdir() + (acct := tmp_path / "acct").mkdir() + for account_home in (home, acct): + (account_home / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "e"}})) + monkeypatch.setenv("HOME", str(home)) + for name in ("CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CODE_CUSTOM_OAUTH_URL"): + monkeypatch.delenv(name, raising=False) + for name, value in config_dir_env(str(home), str(acct)).items(): + monkeypatch.setenv(name, value) + monkeypatch.setattr("spawnllm.backends.claude.sys.platform", "darwin") + calls: list[list[str]] = [] + + def fake_run(argv: list[str], **kwargs: object) -> object: + calls.append(argv) + 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"]) + 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"} + } + def test_env_keychain_miss_seeds_no_credentials(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (account_home := tmp_path / "acct").mkdir() monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(account_home))