diff --git a/conformance/vectors/claude_isolation_sources/api-auth-darwin.json b/conformance/vectors/claude_isolation_sources/api-auth-darwin.json new file mode 100644 index 0000000..e95f72a --- /dev/null +++ b/conformance/vectors/claude_isolation_sources/api-auth-darwin.json @@ -0,0 +1,20 @@ +{ + "name": "api-auth-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": null + }, + "api_auth": true + }, + "expected": { + "account_path": null, + "credentials_path": null, + "keychain_service": null + } +} diff --git a/conformance/vectors/plan/codex-bypass-approvals-and-sandbox.json b/conformance/vectors/plan/codex-bypass-approvals-and-sandbox.json new file mode 100644 index 0000000..6955f98 --- /dev/null +++ b/conformance/vectors/plan/codex-bypass-approvals-and-sandbox.json @@ -0,0 +1,71 @@ +{ + "name": "codex-bypass-approvals-and-sandbox", + "op": "plan", + "input": { + "provider": "codex", + "spec": { + "prompt": "hi", + "model": "gpt-5.5", + "agent": false, + "isolated": true, + "timeout": 180, + "max_attempts": 5, + "api_auth": false, + "schema": null, + "apple": null, + "claude": null, + "codex": { + "bypass_approvals_and_sandbox": true, + "developer_instructions": null, + "enable_hooks": false, + "enable_mcp": false, + "sandbox": null, + "service_tier": "fast" + }, + "gemini": null, + "openai_endpoint": null + }, + "host": { + "platform": "darwin" + } + }, + "expected": { + "kind": "exec", + "argv": [ + "codex", + "exec", + "--ephemeral", + "--dangerously-bypass-approvals-and-sandbox", + "--skip-git-repo-check", + "--color", + "never", + "--model", + "gpt-5.5", + "-c", + "service_tier=fast", + "--ignore-user-config", + "-c", + "features.hooks=false", + "-c", + "features.mcp_servers=false", + "-o", + "${file:result}" + ], + "stdin": "hi", + "files": [ + { + "id": "result", + "suffix": ".json", + "content": null + } + ], + "stdout_to_file": false, + "read_result_from": "file:result", + "env": {}, + "env_unset": [ + "OPENAI_API_KEY", + "CODEX_API_KEY" + ], + "needs_claude_isolation": false + } +} diff --git a/conformance/vectors/plan/codex-dev-instructions-control-char.json b/conformance/vectors/plan/codex-dev-instructions-control-char.json index 91b263f..57feb5a 100644 --- a/conformance/vectors/plan/codex-dev-instructions-control-char.json +++ b/conformance/vectors/plan/codex-dev-instructions-control-char.json @@ -15,6 +15,7 @@ "apple": null, "claude": null, "codex": { + "bypass_approvals_and_sandbox": false, "developer_instructions": "ab", "enable_hooks": false, "enable_mcp": false, diff --git a/conformance/vectors/plan/codex-dev-instructions-literal-true.json b/conformance/vectors/plan/codex-dev-instructions-literal-true.json index 896b6af..2eead17 100644 --- a/conformance/vectors/plan/codex-dev-instructions-literal-true.json +++ b/conformance/vectors/plan/codex-dev-instructions-literal-true.json @@ -15,6 +15,7 @@ "apple": null, "claude": null, "codex": { + "bypass_approvals_and_sandbox": false, "developer_instructions": "true", "enable_hooks": false, "enable_mcp": false, diff --git a/conformance/vectors/plan/codex-dev-instructions-multiline.json b/conformance/vectors/plan/codex-dev-instructions-multiline.json index cb15715..4e57d1a 100644 --- a/conformance/vectors/plan/codex-dev-instructions-multiline.json +++ b/conformance/vectors/plan/codex-dev-instructions-multiline.json @@ -15,6 +15,7 @@ "apple": null, "claude": null, "codex": { + "bypass_approvals_and_sandbox": false, "developer_instructions": "Be terse.\nCite sources.", "enable_hooks": false, "enable_mcp": false, diff --git a/conformance/vectors/plan/codex-dev-instructions-unicode.json b/conformance/vectors/plan/codex-dev-instructions-unicode.json index d73c671..d1a9b5f 100644 --- a/conformance/vectors/plan/codex-dev-instructions-unicode.json +++ b/conformance/vectors/plan/codex-dev-instructions-unicode.json @@ -15,6 +15,7 @@ "apple": null, "claude": null, "codex": { + "bypass_approvals_and_sandbox": false, "developer_instructions": "café ☕ 你好", "enable_hooks": false, "enable_mcp": false, diff --git a/conformance/vectors/plan/codex-enable-hooks.json b/conformance/vectors/plan/codex-enable-hooks.json index ddd65b7..ce492c3 100644 --- a/conformance/vectors/plan/codex-enable-hooks.json +++ b/conformance/vectors/plan/codex-enable-hooks.json @@ -15,6 +15,7 @@ "apple": null, "claude": null, "codex": { + "bypass_approvals_and_sandbox": false, "developer_instructions": null, "enable_hooks": true, "enable_mcp": false, diff --git a/conformance/vectors/plan/codex-enable-mcp.json b/conformance/vectors/plan/codex-enable-mcp.json index fc1489d..25f86a0 100644 --- a/conformance/vectors/plan/codex-enable-mcp.json +++ b/conformance/vectors/plan/codex-enable-mcp.json @@ -15,6 +15,7 @@ "apple": null, "claude": null, "codex": { + "bypass_approvals_and_sandbox": false, "developer_instructions": null, "enable_hooks": false, "enable_mcp": true, diff --git a/conformance/vectors/plan/codex-sandbox-override.json b/conformance/vectors/plan/codex-sandbox-override.json index c466927..80e54bc 100644 --- a/conformance/vectors/plan/codex-sandbox-override.json +++ b/conformance/vectors/plan/codex-sandbox-override.json @@ -15,6 +15,7 @@ "apple": null, "claude": null, "codex": { + "bypass_approvals_and_sandbox": false, "developer_instructions": null, "enable_hooks": false, "enable_mcp": false, diff --git a/conformance/vectors/plan/codex-service-tier-none.json b/conformance/vectors/plan/codex-service-tier-none.json index 6fdb109..fab4c5c 100644 --- a/conformance/vectors/plan/codex-service-tier-none.json +++ b/conformance/vectors/plan/codex-service-tier-none.json @@ -15,6 +15,7 @@ "apple": null, "claude": null, "codex": { + "bypass_approvals_and_sandbox": false, "developer_instructions": null, "enable_hooks": false, "enable_mcp": false, diff --git a/go/coreops.go b/go/coreops.go index 9c888e1..9d5300e 100644 --- a/go/coreops.go +++ b/go/coreops.go @@ -94,7 +94,7 @@ type authProbes struct { } type isolationSources struct { - AccountPath string `json:"account_path"` + AccountPath *string `json:"account_path"` CredentialsPath *string `json:"credentials_path"` KeychainService *string `json:"keychain_service"` } @@ -198,7 +198,7 @@ func coreAuthProbes(provider Provider) (authProbes, error) { }{Provider: provider, Host: map[string]string{"platform": platform(), "home": home()}}) } -func coreIsolationSources() (isolationSources, error) { +func coreIsolationSources(apiAuth bool) (isolationSources, error) { host := map[string]any{ "platform": platform(), "home": home(), @@ -220,8 +220,9 @@ func coreIsolationSources() (isolationSources, error) { host["claude_code_oauth_token_env"] = token } return coreInto[isolationSources]("claude_isolation_sources", struct { - Host map[string]any `json:"host"` - }{Host: host}) + Host map[string]any `json:"host"` + APIAuth bool `json:"api_auth"` + }{Host: host, APIAuth: apiAuth}) } func coreIsolationSeed(accountJSON, credentialsJSON *string) (isolationSeed, error) { diff --git a/go/exec.go b/go/exec.go index 241013d..feedc66 100644 --- a/go/exec.go +++ b/go/exec.go @@ -52,7 +52,7 @@ func runExecPlan(ctx context.Context, plan execPlan, spec RunSpec) (output strin env := plan.Env if plan.NeedsClaudeIsolation { - dir, seedEnv, cleanup, e := seedClaudeIsolation() + dir, seedEnv, cleanup, e := seedClaudeIsolation(spec.APIAuth) if e != nil { return "", 0, "", false, e } diff --git a/go/isolate.go b/go/isolate.go index 0332863..689f932 100644 --- a/go/isolate.go +++ b/go/isolate.go @@ -9,12 +9,15 @@ import ( "strings" ) -func seedClaudeIsolation() (string, map[string]string, func(), error) { - sources, err := coreIsolationSources() +func seedClaudeIsolation(apiAuth bool) (string, map[string]string, func(), error) { + sources, err := coreIsolationSources(apiAuth) if err != nil { return "", nil, nil, err } - accountJSON := readFileOpt(sources.AccountPath) + var accountJSON *string + if sources.AccountPath != nil { + accountJSON = readFileOpt(*sources.AccountPath) + } var credentialsJSON *string if sources.CredentialsPath != nil { credentialsJSON = readFileOpt(*sources.CredentialsPath) diff --git a/go/spec.go b/go/spec.go index 04e036e..b9468f5 100644 --- a/go/spec.go +++ b/go/spec.go @@ -116,6 +116,9 @@ type CodexConfig struct { // other value selects that tier. ServiceTier *string DeveloperInstructions string + // BypassApprovalsAndSandbox replaces --sandbox with + // --dangerously-bypass-approvals-and-sandbox for an externally sandboxed host. + BypassApprovalsAndSandbox bool } // GeminiConfig passes knobs through to the gemini and antigravity CLIs. @@ -216,11 +219,12 @@ type coreClaude struct { } type coreCodex struct { - DeveloperInstructions *string `json:"developer_instructions"` - EnableHooks bool `json:"enable_hooks"` - EnableMCP bool `json:"enable_mcp"` - Sandbox *string `json:"sandbox"` - ServiceTier *string `json:"service_tier"` + BypassApprovalsAndSandbox bool `json:"bypass_approvals_and_sandbox"` + DeveloperInstructions *string `json:"developer_instructions"` + EnableHooks bool `json:"enable_hooks"` + EnableMCP bool `json:"enable_mcp"` + Sandbox *string `json:"sandbox"` + ServiceTier *string `json:"service_tier"` } type coreGemini struct { @@ -339,11 +343,12 @@ func coreCodexOf(c *CodexConfig) *coreCodex { tier = nil } return &coreCodex{ - DeveloperInstructions: optString(c.DeveloperInstructions), - EnableHooks: c.EnableHooks, - EnableMCP: c.EnableMCP, - Sandbox: optString(c.Sandbox), - ServiceTier: tier, + BypassApprovalsAndSandbox: c.BypassApprovalsAndSandbox, + DeveloperInstructions: optString(c.DeveloperInstructions), + EnableHooks: c.EnableHooks, + EnableMCP: c.EnableMCP, + Sandbox: optString(c.Sandbox), + ServiceTier: tier, } } diff --git a/rust/conformance-gen/src/cases.rs b/rust/conformance-gen/src/cases.rs index 6b25949..41bb3dd 100644 --- a/rust/conformance-gen/src/cases.rs +++ b/rust/conformance-gen/src/cases.rs @@ -60,6 +60,7 @@ fn claude() -> ClaudeConfig { fn codex() -> CodexConfig { CodexConfig { + bypass_approvals_and_sandbox: false, developer_instructions: None, enable_hooks: false, enable_mcp: false, @@ -390,6 +391,13 @@ fn plan_cases() -> Vec { ..codex() }, ), + codex_cfg_case( + "codex-bypass-approvals-and-sandbox", + CodexConfig { + bypass_approvals_and_sandbox: true, + ..codex() + }, + ), codex_cfg_case( "codex-enable-hooks", CodexConfig { @@ -1075,8 +1083,15 @@ fn iso_sources_case(name: &str, host: IsoHost) -> Case { } } +fn iso_api_case(name: &str, host: IsoHost) -> Case { + let mut case = iso_sources_case(name, host); + case.input["api_auth"] = json!(true); + case +} + fn iso_sources_cases() -> Vec { vec![ + iso_api_case("api-auth-darwin", ISO_DARWIN), iso_sources_case("default-home-darwin", ISO_DARWIN), iso_sources_case("default-home-linux", ISO_LINUX), iso_sources_case( diff --git a/rust/spawnllm-core/src/isolate.rs b/rust/spawnllm-core/src/isolate.rs index fca7fd2..dcacf95 100644 --- a/rust/spawnllm-core/src/isolate.rs +++ b/rust/spawnllm-core/src/isolate.rs @@ -12,6 +12,8 @@ use crate::{OpError, OpResult, from_input, unimplemented}; #[derive(Debug, Deserialize)] struct IsolationSourcesInput { host: IsolationHost, + #[serde(default)] + api_auth: bool, } #[derive(Debug, Deserialize)] @@ -29,7 +31,7 @@ struct IsolationHost { #[derive(Debug, Serialize)] struct IsolationSources { - account_path: String, + account_path: Option, credentials_path: Option, keychain_service: Option, } @@ -113,6 +115,13 @@ fn config_dir_digest(config_dir_env: &str) -> String { // 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 { + if input.api_auth { + return IsolationSources { + account_path: None, + credentials_path: None, + keychain_service: None, + }; + } let host = input.host; let inherits_token = is_set(&host.claude_code_oauth_token_env); let default_home = format!("{}/.claude", host.home); @@ -146,7 +155,7 @@ fn isolation_sources(input: IsolationSourcesInput) -> IsolationSources { format!("Claude Code{oauth_file_suffix}-credentials{digest}") }); IsolationSources { - account_path, + account_path: Some(account_path), credentials_path: (!inherits_token) .then(|| format!("{credentials_home}/.credentials.json")), keychain_service, diff --git a/rust/spawnllm-core/src/plan/codex.rs b/rust/spawnllm-core/src/plan/codex.rs index 35c8ad2..2825bdb 100644 --- a/rust/spawnllm-core/src/plan/codex.rs +++ b/rust/spawnllm-core/src/plan/codex.rs @@ -14,20 +14,24 @@ pub(super) fn plan(spec: &RunSpec) -> InvocationPlan { .map_or((spec.model.as_str(), None), |(model, effort)| { (model, (!effort.is_empty()).then_some(effort)) }); - let mut argv = vec![ - "codex".into(), - "exec".into(), - "--ephemeral".into(), - "--sandbox".into(), - config - .and_then(|config| config.sandbox.clone()) - .unwrap_or_else(|| "read-only".into()), + let mut argv: Vec = vec!["codex".into(), "exec".into(), "--ephemeral".into()]; + if config.is_some_and(|config| config.bypass_approvals_and_sandbox) { + argv.push("--dangerously-bypass-approvals-and-sandbox".into()); + } else { + argv.extend([ + "--sandbox".into(), + config + .and_then(|config| config.sandbox.clone()) + .unwrap_or_else(|| "read-only".into()), + ]); + } + argv.extend([ "--skip-git-repo-check".into(), "--color".into(), "never".into(), "--model".into(), model.into(), - ]; + ]); if let Some(effort) = effort { push_config(&mut argv, format!("model_reasoning_effort={effort}")); diff --git a/rust/spawnllm-core/src/wire.rs b/rust/spawnllm-core/src/wire.rs index a0e7bc1..3bd3db8 100644 --- a/rust/spawnllm-core/src/wire.rs +++ b/rust/spawnllm-core/src/wire.rs @@ -83,6 +83,7 @@ pub struct ClaudeConfig { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CodexConfig { + pub bypass_approvals_and_sandbox: bool, pub developer_instructions: Option, pub enable_hooks: bool, pub enable_mcp: bool, diff --git a/rust/spawnllm/src/isolate.rs b/rust/spawnllm/src/isolate.rs index 01bdef3..e6f4e34 100644 --- a/rust/spawnllm/src/isolate.rs +++ b/rust/spawnllm/src/isolate.rs @@ -17,7 +17,7 @@ use crate::host::{home, platform}; #[derive(Debug, Deserialize)] struct Sources { - account_path: String, + account_path: Option, credentials_path: Option, keychain_service: Option, } @@ -40,7 +40,7 @@ pub(crate) struct Isolation { pub(crate) env: BTreeMap, } -pub(crate) async fn seed_isolation() -> Result { +pub(crate) async fn seed_isolation(api_auth: bool) -> Result { let sources: Sources = core_op( "claude_isolation_sources", json!({ "host": { @@ -50,10 +50,13 @@ pub(crate) async fn seed_isolation() -> Result { "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(), - } }), + }, "api_auth": api_auth }), )?; - let account_json = std::fs::read_to_string(&sources.account_path).ok(); + let account_json = sources + .account_path + .as_deref() + .and_then(|path| std::fs::read_to_string(path).ok()); let credentials_json = match sources .credentials_path .as_deref() diff --git a/rust/spawnllm/src/run.rs b/rust/spawnllm/src/run.rs index b67bc4b..592cf37 100644 --- a/rust/spawnllm/src/run.rs +++ b/rust/spawnllm/src/run.rs @@ -151,7 +151,7 @@ async fn exec_loop( wants_value: bool, ) -> Response { let isolation = if plan.needs_claude_isolation { - match crate::isolate::seed_isolation().await { + match crate::isolate::seed_isolation(spec.api_auth).await { Ok(isolation) => Some(isolation), Err(error) => return error_response(spec, error, Vec::new()), } diff --git a/rust/spawnllm/src/spec.rs b/rust/spawnllm/src/spec.rs index 9ecbc69..1e01479 100644 --- a/rust/spawnllm/src/spec.rs +++ b/rust/spawnllm/src/spec.rs @@ -43,9 +43,11 @@ pub struct ClaudeConfig { /// `service_tier` defaults to `"fast"`, matching the Python reference: an isolated /// run passes `--ignore-user-config`, dropping any user-level tier pin, and the /// standard tier turns long prompts into multi-minute runs. Set it to `None` to -/// drop the flag. +/// drop the flag. `bypass_approvals_and_sandbox` replaces `--sandbox` with +/// `--dangerously-bypass-approvals-and-sandbox` for an externally sandboxed host. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CodexConfig { + pub bypass_approvals_and_sandbox: bool, pub developer_instructions: Option, pub enable_hooks: bool, pub enable_mcp: bool, @@ -56,6 +58,7 @@ pub struct CodexConfig { impl Default for CodexConfig { fn default() -> Self { Self { + bypass_approvals_and_sandbox: false, developer_instructions: None, enable_hooks: false, enable_mcp: false, diff --git a/spawnllm/backends/base.py b/spawnllm/backends/base.py index 8e482dd..85e1877 100644 --- a/spawnllm/backends/base.py +++ b/spawnllm/backends/base.py @@ -396,7 +396,7 @@ def binary_path(self) -> str: """ return self.binary - def claude_isolation(self) -> ClaudeIsolation: + def claude_isolation(self, api_auth: bool) -> ClaudeIsolation: """Return the config home a claude run substitutes into `${isolated_config_dir}` and the env it adds.""" raise NotImplementedError @@ -436,7 +436,7 @@ def invocation(self, spec: RunSpec) -> Invocation: argv = [tokens.get(arg, arg) for arg in plan["argv"]] env = plan["env"] if plan["needs_claude_isolation"]: - isolation = self.claude_isolation() + isolation = self.claude_isolation(spec.api_auth) 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() @@ -468,7 +468,7 @@ def env(self, spec: RunSpec) -> dict[str, str]: plan = self.core_plan(spec) if not plan["needs_claude_isolation"]: return plan["env"] - isolation = self.claude_isolation() + isolation = self.claude_isolation(spec.api_auth) return { key: value.replace("${isolated_config_dir}", isolation.config_dir) for key, value in plan["env"].items() } | isolation.env diff --git a/spawnllm/backends/claude.py b/spawnllm/backends/claude.py index 7747b52..72a7045 100644 --- a/spawnllm/backends/claude.py +++ b/spawnllm/backends/claude.py @@ -63,8 +63,9 @@ class ClaudeCliBackend(CliBackend): schema_dialect: ClassVar[str | None] = "anthropic" _isolated_config_dir: str | None = None + _api_config_dir: str | None = None - def claude_isolation(self) -> ClaudeIsolation: + def claude_isolation(self, api_auth: bool) -> ClaudeIsolation: """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, @@ -76,7 +77,9 @@ def claude_isolation(self) -> ClaudeIsolation: 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. + only ever lives in memory. An `api_auth` run names no source at all, so it + reads no account, credentials file, or Keychain item and gets an empty home + of its own. """ sources = _core.dispatch( "claude_isolation_sources", @@ -88,22 +91,28 @@ def claude_isolation(self) -> ClaudeIsolation: "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"), - } + }, + "api_auth": api_auth, }, ) - account_json = read_file_opt(sources["account_path"]) + account_json = read_file_opt(sources["account_path"]) if sources["account_path"] else None 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} ) - if self._isolated_config_dir is None: + cached = self._api_config_dir if api_auth else self._isolated_config_dir + if cached 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"]) + cached = str(config_dir) + if api_auth: + self._api_config_dir = cached + else: + self._isolated_config_dir = cached + return ClaudeIsolation(cached, seed["env"]) diff --git a/spawnllm/spec.py b/spawnllm/spec.py index f801969..c16b7b4 100644 --- a/spawnllm/spec.py +++ b/spawnllm/spec.py @@ -105,10 +105,16 @@ class CodexConfig: user-level tier pin applies only with `isolated=False`. `developer_instructions` injects the system-prompt layer via `-c developer_instructions=`, serialized as a TOML string so any text — multi-line, or TOML-ambiguous words like `true` — - arrives as a string. + arrives as a string. `bypass_approvals_and_sandbox` replaces `--sandbox` with + `--dangerously-bypass-approvals-and-sandbox` for a host that is already + externally sandboxed; it cannot be combined with `sandbox`. Example: >>> CodexConfig(sandbox="read-only", enable_mcp=True) + + Raises: + ValueError: When `bypass_approvals_and_sandbox` is set together with a + `sandbox`, two policies the CLI cannot apply at once. """ sandbox: str | None = None @@ -116,6 +122,11 @@ class CodexConfig: enable_mcp: bool = False service_tier: str | None = "fast" developer_instructions: str | None = None + bypass_approvals_and_sandbox: bool = False + + def __post_init__(self) -> None: + if self.bypass_approvals_and_sandbox and self.sandbox is not None: + raise ValueError("CodexConfig accepts either sandbox or bypass_approvals_and_sandbox, not both") @dataclass(frozen=True, slots=True) diff --git a/tests/test_backends.py b/tests/test_backends.py index d4492d1..e590f65 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -109,6 +109,7 @@ def test_config_section_is_dataclass_asdict(self) -> None: "enable_mcp": False, "service_tier": "standard", "developer_instructions": None, + "bypass_approvals_and_sandbox": False, } def test_gemini_config_distinguishes_nil_from_empty_extensions(self) -> None: @@ -733,3 +734,18 @@ def handler(request: httpx.Request) -> httpx.Response: def test_extract_sync_drives_structured_path(self, monkeypatch: pytest.MonkeyPatch) -> None: mock_transport(monkeypatch, lambda _request: httpx.Response(200, json=completion(json.dumps({"x": 9})))) assert extract_sync("hi", M, backend=OpenAiEndpointBackend(ENDPOINT, "qwen3")) == M(x=9) + + +def test_api_auth_isolation_reads_no_account_credentials_or_keychain(monkeypatch: pytest.MonkeyPatch) -> None: + import spawnllm.backends.claude as claude + + for name in ("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CONFIG_DIR"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(claude, "read_file_opt", lambda path: pytest.fail(f"API isolation read {path}")) + monkeypatch.setattr(claude, "keychain_credentials", lambda service: pytest.fail(f"API isolation read {service}")) + backend = claude.ClaudeCliBackend() + spec = RunSpec(prompt="hi", model="haiku", api_auth=True, env={"ANTHROPIC_API_KEY": "sk-synthetic-isolation-7e21"}) + env = backend.env(spec) + assert "CLAUDE_CODE_OAUTH_TOKEN" not in env + assert os.listdir(env["CLAUDE_CONFIG_DIR"]) == []