From 7a6837e0225ebc35505bb0e71ce8f13ff5f486bc Mon Sep 17 00:00:00 2001 From: dinghengda Date: Wed, 1 Jul 2026 17:33:13 +0800 Subject: [PATCH] sandbox codex-login: inject oauth token only, drop config rewrite auth.json can also hold a long-lived OPENAI_API_KEY next to the oauth tokens. The previous code injected the whole file, so that key could end up in the sandbox. Now only the oauth tokens are injected (OPENAI_API_KEY set to null) and api-key-only auth is rejected. Also removed the config.toml rewrite and the --keep-model-config flag; the config is left as the platform wrote it. Use `codex exec -c model_provider=openai` to select the subscription. --- agentkit/auth/model_login.py | 193 ++++++++---------- .../toolkit/cli/sandbox/cli_model_login.py | 104 +++------- tests/auth/test_model_login.py | 175 ++++++++-------- tests/toolkit/cli/test_codex_login_cli.py | 58 +++--- 4 files changed, 247 insertions(+), 283 deletions(-) diff --git a/agentkit/auth/model_login.py b/agentkit/auth/model_login.py index 5c85e9e..040d545 100644 --- a/agentkit/auth/model_login.py +++ b/agentkit/auth/model_login.py @@ -12,28 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Bring a user's *own* third-party model subscription into the sandbox. - -Many foreign models (ChatGPT/Codex, Claude Code) are used via a JWT obtained by an -interactive SSO login, not a static API key. A user who subscribed to one of those -plans wants the **sandbox's** codex to run on *their* subscription. - -The agentkit UserPool does not (and need not) federate with OpenAI/Anthropic. Codex -already supports SSO **locally**; in the sandbox it is "remote", so the agreed design -is dead simple and is what this module implements: - - 1. The user runs the provider's native SSO **on their PC** (``codex login`` for - Codex/ChatGPT, ``claude`` for Claude Code). That writes the provider's native - credential file: - Codex -> $CODEX_HOME/auth.json (default ~/.codex/auth.json) - Claude -> ~/.claude/.credentials.json (or the macOS Keychain) - 2. We read that file verbatim and inject it into the **same native path inside - the user's private sandbox** — so the sandbox's codex finds its token exactly - where it natively looks. No proxy, no federation: the sandbox refreshes the - token itself against the provider, just like a local install would. - -This module is pure (stdlib only) and side-effect-light so it is unit-testable; the -network/forwarding lives in the CLI layer (``cli_accesscontrol.py``). +"""Read a local model-subscription login and inject it into a sandbox. + +Codex/ChatGPT and Claude Code log in over OAuth and store the token in a local file +($CODEX_HOME/auth.json for codex, ~/.claude/.credentials.json or the macOS Keychain for +Claude). This reads that file and writes the token to the same path inside the sandbox, +so the sandbox's codex runs on the user's subscription. codex refreshes the token itself. + +Only the OAuth token is injected. The same file can also hold a long-lived API key +(codex: OPENAI_API_KEY); that is stripped before injection and an api-key-only file is +rejected, so a long-lived key never reaches the sandbox. See sanitize_*_for_injection. + +The sandbox config.toml is not touched; select the subscription at exec with +`codex exec -c model_provider=openai`. Stdlib only; the sandbox transport lives in +sandbox/cli_model_login.py. """ from __future__ import annotations @@ -42,18 +34,19 @@ import datetime import json import os -import re import sys from pathlib import Path from typing import Callable, Optional -# ── Codex / ChatGPT facts ──────────────────────────────────────────────────── +# Codex / ChatGPT facts CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" # codex CLI's public OAuth client (the id_token aud) CODEX_OAUTH_NAMESPACE = "https://api.openai.com/auth" # claim namespace holding the ChatGPT plan -DEFAULT_SANDBOX_CODEX_HOME = "/home/gem/.codex" # informational; the shell resolves ${CODEX_HOME:-$HOME/.codex} +CODEX_BUILTIN_PROVIDER = "openai" # codex's reserved built-in provider that uses ChatGPT auth CODEX_INJECT_MARKER = "AGENTKIT_CODEX_INJECTED" CLAUDE_INJECT_MARKER = "AGENTKIT_CLAUDE_INJECTED" CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials" +# codex auth.json OAuth token fields we carry into the sandbox (everything else is dropped). +CODEX_OAUTH_TOKEN_FIELDS = ("id_token", "access_token", "refresh_token", "account_id") PROVIDERS = ("codex", "claude") @@ -62,9 +55,9 @@ class ModelLoginError(RuntimeError): """A user-facing failure resolving or injecting a model subscription token.""" -# ── small helpers ──────────────────────────────────────────────────────────── +# small helpers def b64(data: str | bytes) -> str: - """Standard base64 (single line, no newlines) — safe to embed in a shell ``printf``.""" + """Standard base64 (single line, no newlines) - safe to embed in a shell ``printf``.""" if isinstance(data, str): data = data.encode("utf-8") return base64.b64encode(data).decode("ascii") @@ -86,7 +79,7 @@ def decode_jwt_claims(jwt: str) -> dict: raise ModelLoginError(f"cannot decode JWT claims: {exc}") from exc -# ── Codex (ChatGPT) ────────────────────────────────────────────────────────── +# Codex (ChatGPT) def codex_home_path(explicit: Optional[str] = None) -> Path: """Resolve the LOCAL codex home: --codex-home > $CODEX_HOME > ~/.codex.""" if explicit: @@ -110,15 +103,40 @@ def read_codex_auth(path: str | Path) -> dict: return data -def validate_codex_auth(data: dict) -> None: - """A usable codex auth has ChatGPT tokens (SSO) or an API key.""" +def codex_has_oauth(data: dict) -> bool: tokens = data.get("tokens") - has_tokens = isinstance(tokens, dict) and bool(tokens.get("id_token") or tokens.get("access_token")) - has_key = bool(data.get("OPENAI_API_KEY")) - if not (has_tokens or has_key): + return isinstance(tokens, dict) and bool(tokens.get("id_token") or tokens.get("access_token")) + + +def validate_codex_auth(data: dict) -> None: + """A usable codex auth has ChatGPT OAuth tokens or an API key. Only the OAuth path is + injected; api-key-only auth is rejected in sanitize_codex_auth_for_injection.""" + if not (codex_has_oauth(data) or data.get("OPENAI_API_KEY")): + raise ModelLoginError( + "codex auth.json has neither ChatGPT tokens nor an API key; run `codex login` first" + ) + + +def sanitize_codex_auth_for_injection(data: dict) -> dict: + """Return only the OAuth token material to inject, dropping any API key. + + codex keeps a long-lived OPENAI_API_KEY in the same auth.json as the OAuth tokens. + This returns just the tokens with OPENAI_API_KEY set to null; an api-key-only auth + (no OAuth login) is rejected. + """ + if not codex_has_oauth(data): raise ModelLoginError( - "codex auth.json has neither ChatGPT tokens nor an API key — run `codex login` first" + "no ChatGPT OAuth login in your codex auth. An API key is not injected into the " + "sandbox; run `codex login` (ChatGPT SSO) first." ) + tokens = data.get("tokens") or {} + safe_tokens = {k: tokens[k] for k in CODEX_OAUTH_TOKEN_FIELDS if tokens.get(k) is not None} + return { + "OPENAI_API_KEY": None, # do not inject the API key + "auth_mode": "chatgpt", + "tokens": safe_tokens, + "last_refresh": data.get("last_refresh"), + } def codex_auth_summary(data: dict) -> dict: @@ -127,6 +145,8 @@ def codex_auth_summary(data: dict) -> dict: summary: dict = { "provider": "codex (ChatGPT)", "auth_mode": data.get("auth_mode") or ("chatgpt" if tokens else "apikey"), + "has_oauth_login": codex_has_oauth(data), + "has_local_api_key": bool(data.get("OPENAI_API_KEY")), # present locally; NOT injected "has_refresh_token": bool(tokens.get("refresh_token")), "account_id": tokens.get("account_id"), } @@ -160,7 +180,7 @@ def run_codex_login( subprocess.run([codex_bin, "login"], env=env, timeout=timeout, check=True) # noqa: S603 except FileNotFoundError as exc: raise ModelLoginError( - f"`{codex_bin}` not found on PATH — install the Codex CLI and run `codex login`, " + f"`{codex_bin}` not found on PATH - install the Codex CLI and run `codex login`, " f"or pass --auth-file " ) from exc except subprocess.CalledProcessError as exc: @@ -191,7 +211,7 @@ def resolve_local_codex_auth( if not path.exists(): if not allow_login: raise ModelLoginError( - f"no codex auth at {path} — run `codex login` (or pass --auth-file / --no-login off)" + f"no codex auth at {path} - run `codex login` (or pass --auth-file / drop --no-login)" ) (login_runner or run_codex_login)(codex_home=home, timeout=login_timeout) if not path.exists(): @@ -201,76 +221,25 @@ def resolve_local_codex_auth( return path, data -# ── Codex config.toml: switch the sandbox codex to the ChatGPT subscription ── -_TOP_MODEL_PIN = re.compile(r"^\s*(model|model_provider|review_model)\s*=") -_AUTH_METHOD = re.compile(r"^\s*preferred_auth_method\s*=") -_TABLE_HEADER = re.compile(r"^\s*\[") - - -def rewrite_codex_config_for_chatgpt(toml_text: str) -> str: - """Switch an existing codex config.toml to ChatGPT-subscription auth, preserving everything else. - - The sandbox's default config pins a custom ``model_provider``/``model`` to a Volcengine Ark - endpoint that authenticates with an API key (``env_key``); in that mode codex never looks at the - injected ChatGPT token. To use the subscription we: - * drop the top-level ``model`` / ``model_provider`` / ``review_model`` pins, so codex falls back - to its built-in ``openai`` provider and the subscription's default model, and - * force ``preferred_auth_method = "chatgpt"`` so the OAuth token wins over any stray API key. - Tables (``[tui]``, ``[projects.*]``, ``[mcp_servers.*]``, ``[model_providers.*]``) and other - top-level keys (approval_policy, sandbox_mode, model_reasoning_effort, …) are kept verbatim. - """ - out: list[str] = [] - seen_table = False - for line in toml_text.splitlines(): - if _TABLE_HEADER.match(line): - seen_table = True - if _AUTH_METHOD.match(line): - continue # re-added at the top - if not seen_table and _TOP_MODEL_PIN.match(line): - continue - out.append(line) - body = "\n".join(out).strip("\n") - return 'preferred_auth_method = "chatgpt"\n' + (body + "\n" if body else "") - - -def minimal_chatgpt_codex_config() -> str: - """A config.toml for a sandbox that has no codex config yet — ChatGPT auth, headless-friendly.""" +def build_codex_injection_command(*, auth_data: dict) -> str: + """POSIX-sh command that writes the sanitized auth.json (OAuth only, API key stripped) into + ${CODEX_HOME:-$HOME/.codex} at 0600 and prints a marker. auth_data is sanitized here so the + transport cannot inject an API key.""" + payload = json.dumps(sanitize_codex_auth_for_injection(auth_data), ensure_ascii=False) return "\n".join( [ - 'preferred_auth_method = "chatgpt"', - 'approval_policy = "never"', - 'sandbox_mode = "danger-full-access"', - "", - '[projects."/home/gem"]', - 'trust_level = "trusted"', - "", + "set -e", + 'CH="${CODEX_HOME:-$HOME/.codex}"', + 'mkdir -p "$CH"', + "umask 077", + f"printf %s '{b64(payload)}' | base64 -d > \"$CH/auth.json\"", + 'chmod 600 "$CH/auth.json"', + f'echo "{CODEX_INJECT_MARKER} $CH"', ] ) -def read_codex_config_command() -> str: - """Shell command that prints the sandbox's current codex config.toml (empty if none).""" - return 'cat "${CODEX_HOME:-$HOME/.codex}/config.toml" 2>/dev/null || true' - - -def build_codex_injection_command(*, auth_json: str, config_toml: Optional[str] = None) -> str: - """A single POSIX-sh command that writes auth.json (and optionally config.toml) into the - sandbox's native codex home (``${CODEX_HOME:-$HOME/.codex}``), 0600, and prints a marker.""" - lines = [ - "set -e", - 'CH="${CODEX_HOME:-$HOME/.codex}"', - 'mkdir -p "$CH"', - "umask 077", - f"printf %s '{b64(auth_json)}' | base64 -d > \"$CH/auth.json\"", - 'chmod 600 "$CH/auth.json"', - ] - if config_toml is not None: - lines.append(f"printf %s '{b64(config_toml)}' | base64 -d > \"$CH/config.toml\"") - lines.append(f'echo "{CODEX_INJECT_MARKER} $CH"') - return "\n".join(lines) - - -# ── Claude Code ────────────────────────────────────────────────────────────── +# Claude Code def claude_creds_path(explicit: Optional[str] = None) -> Path: if explicit: return Path(explicit).expanduser() @@ -308,7 +277,7 @@ def read_claude_creds(*, creds_file: Optional[str] = None, allow_keychain: bool raw = _read_macos_keychain(CLAUDE_KEYCHAIN_SERVICE) if not raw: raise ModelLoginError( - "no Claude Code credentials in ~/.claude/.credentials.json or the macOS Keychain — " + "no Claude Code credentials in ~/.claude/.credentials.json or the macOS Keychain - " "run `claude` and log in with your subscription first" ) try: @@ -317,7 +286,7 @@ def read_claude_creds(*, creds_file: Optional[str] = None, allow_keychain: bool raise ModelLoginError("Claude Keychain entry is not valid JSON") from exc else: raise ModelLoginError( - "no Claude Code credentials at ~/.claude/.credentials.json — " + "no Claude Code credentials at ~/.claude/.credentials.json - " "run `claude` and log in with your subscription first" ) if not isinstance(data, dict): @@ -331,6 +300,18 @@ def validate_claude_creds(data: dict) -> None: raise ModelLoginError("Claude credentials missing claudeAiOauth.accessToken") +def sanitize_claude_creds_for_injection(data: dict) -> dict: + """Return only the claudeAiOauth object; other top-level fields (e.g. a stored API key) + are dropped.""" + oauth = data.get("claudeAiOauth") + if not (isinstance(oauth, dict) and oauth.get("accessToken")): + raise ModelLoginError( + "no Claude OAuth login found. An API key is not injected into the sandbox; " + "run `claude` and log in with your subscription first." + ) + return {"claudeAiOauth": oauth} + + def claude_creds_summary(data: dict) -> dict: oauth = data.get("claudeAiOauth") or {} summary: dict = { @@ -345,15 +326,17 @@ def claude_creds_summary(data: dict) -> dict: return {k: v for k, v in summary.items() if v is not None} -def build_claude_injection_command(*, creds_json: str) -> str: - """A single POSIX-sh command that writes Claude Code creds into ``$HOME/.claude``, 0600.""" +def build_claude_injection_command(*, creds_data: dict) -> str: + """POSIX-sh command that writes the sanitized Claude creds into + $HOME/.claude/.credentials.json at 0600.""" + payload = json.dumps(sanitize_claude_creds_for_injection(creds_data), ensure_ascii=False) return "\n".join( [ "set -e", 'CD="$HOME/.claude"', 'mkdir -p "$CD"', "umask 077", - f"printf %s '{b64(creds_json)}' | base64 -d > \"$CD/.credentials.json\"", + f"printf %s '{b64(payload)}' | base64 -d > \"$CD/.credentials.json\"", 'chmod 600 "$CD/.credentials.json"', f'echo "{CLAUDE_INJECT_MARKER} $CD"', ] diff --git a/agentkit/toolkit/cli/sandbox/cli_model_login.py b/agentkit/toolkit/cli/sandbox/cli_model_login.py index 67c8059..f16a52b 100644 --- a/agentkit/toolkit/cli/sandbox/cli_model_login.py +++ b/agentkit/toolkit/cli/sandbox/cli_model_login.py @@ -12,20 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""`agentkit sandbox codex-login` — bring your own model subscription into a sandbox. +"""`agentkit sandbox codex-login`: inject a local model subscription into a sandbox. -Codex/ChatGPT and Claude Code are used via a JWT obtained by an interactive SSO login, not a -static API key. Codex supports that SSO **locally**; in the sandbox it is remote. So instead of -federating the platform UserPool with OpenAI/Anthropic, this command: - - 1. reads the provider's native credential the local SSO already produced - (``$CODEX_HOME/auth.json`` for Codex, ``~/.claude/.credentials.json`` for Claude Code), and - 2. injects it into the **same native path inside the sandbox session**, so the sandbox's codex - finds its token exactly where it natively looks. - -The sandbox refreshes the token itself against the provider, just like a local install would. The -heavy lifting (resolve / validate / redact / config rewrite / build the injection command) lives in -``agentkit.auth.model_login``; this file is only the sandbox-session transport + CLI surface. +Reads the OAuth login codex/claude wrote locally ($CODEX_HOME/auth.json, +~/.claude/.credentials.json) and writes the token to the same path in the sandbox session. +Only the OAuth token is injected; an API key in the same file is stripped. The sandbox config +is not touched; select the subscription at exec with `codex exec -c model_provider=openai`. """ from __future__ import annotations @@ -58,7 +50,7 @@ def _redact_inject(cmd: str) -> str: """Redact the base64 token blob in an injection command (for --dry-run display).""" return re.sub( r"printf %s '([A-Za-z0-9+/=]+)'", - lambda m: f"printf %s ''", + lambda m: f"printf %s ''", cmd, ) @@ -98,11 +90,6 @@ def codex_login_command( "--login/--no-login", help="If no local credential exists, trigger `codex login` (browser SSO) once.", ), - keep_model_config: bool = typer.Option( - False, - "--keep-model-config", - help="Only inject the token; do not switch the sandbox codex config to subscription auth.", - ), tool_id: Optional[str] = typer.Option( None, "--tool-id", @@ -119,10 +106,10 @@ def codex_login_command( help="Resolve the local credential and print what would be injected (token redacted); no session.", ), ) -> None: - """Inject your own ChatGPT/Codex or Claude Code subscription token into a sandbox session. + """Inject your ChatGPT/Codex or Claude Code subscription token into a sandbox session. - Run the provider's local SSO once (codex supports it locally), then this injects the native - token into the sandbox's native location so the sandbox codex runs on your subscription. + Only the OAuth token is injected; an API key in the same file is not. The sandbox config is + left untouched; select the subscription with `codex exec -c model_provider=openai`. """ from agentkit.auth import model_login as ml @@ -137,47 +124,38 @@ def codex_login_command( codex_home=codex_home, auth_file=auth_file, allow_login=login ) summary = ml.codex_auth_summary(data) + build_cmd = ml.build_codex_injection_command(auth_data=data) # sanitizes: OAuth only + marker = ml.CODEX_INJECT_MARKER else: # claude data = ml.read_claude_creds(creds_file=auth_file) - ml.validate_claude_creds(data) src = ml.claude_creds_path(auth_file) summary = ml.claude_creds_summary(data) + build_cmd = ml.build_claude_injection_command(creds_data=data) # sanitizes: OAuth only + marker = ml.CLAUDE_INJECT_MARKER except ml.ModelLoginError as exc: error(str(exc)) typer.secho(f"\nLocal credential: {src}", fg=typer.colors.CYAN, err=True) typer.echo(json.dumps(summary, indent=2, ensure_ascii=False)) + if summary.get("has_local_api_key"): + typer.secho( + " note: a local API key was found and is not injected, only the OAuth login.", + fg=typer.colors.YELLOW, + err=True, + ) if summary.get("id_token_expired") and not summary.get("has_refresh_token"): typer.secho( - " warning: token expired and has no refresh_token — re-run `codex login` locally first.", + " warning: token expired and has no refresh_token; re-run `codex login` locally first.", fg=typer.colors.YELLOW, err=True, ) - payload = json.dumps(data, ensure_ascii=False) + if dry_run: + typer.secho("\n(dry-run) would inject (OAuth token only, redacted):", fg=typer.colors.CYAN, err=True) + typer.secho(_redact_inject(build_cmd), err=True) + raise typer.Exit(0) - # 2) build the injection (codex also switches its config to subscription auth unless --keep-model-config) - if provider == "codex": - if dry_run: - typer.secho("\n— dry-run: read current sandbox codex config (read-only) —", fg=typer.colors.CYAN, err=True) - typer.secho(f" $ {ml.read_codex_config_command()}", err=True) - cmd = ml.build_codex_injection_command( - auth_json=payload, - config_toml=None if keep_model_config else ml.minimal_chatgpt_codex_config(), - ) - typer.secho("\n— dry-run: would inject (token redacted) —", fg=typer.colors.CYAN, err=True) - typer.secho(_redact_inject(cmd), err=True) - raise typer.Exit(0) - marker = ml.CODEX_INJECT_MARKER - else: # claude - cmd = ml.build_claude_injection_command(creds_json=payload) - marker = ml.CLAUDE_INJECT_MARKER - if dry_run: - typer.secho("\n— dry-run: would inject (token redacted) —", fg=typer.colors.CYAN, err=True) - typer.secho(_redact_inject(cmd), err=True) - raise typer.Exit(0) - - # 3) ensure the sandbox session, then inject over its shell-exec endpoint + # 2) ensure the sandbox session, then inject over its shell-exec endpoint try: session = ensure_sandbox_session( session_id=session_id, @@ -193,26 +171,13 @@ def codex_login_command( if not isinstance(sid, str) or not sid: error("sandbox session missing session_id") - if provider == "codex": - new_cfg = None - if not keep_model_config: - cur = _shell_output( - _exec_shell_command(session, ml.read_codex_config_command(), quiet_errors=True) - ) - new_cfg = ( - ml.rewrite_codex_config_for_chatgpt(cur) - if cur.strip() - else ml.minimal_chatgpt_codex_config() - ) - cmd = ml.build_codex_injection_command(auth_json=payload, config_toml=new_cfg) - - out = _shell_output(_exec_shell_command(session, cmd)) + out = _shell_output(_exec_shell_command(session, build_cmd)) if marker not in out: error(f"injection did not confirm (marker missing). sandbox output: {out[:200]}") where = out.split(marker, 1)[1].strip() or "" typer.secho( - f"\n✓ injected your {summary.get('provider', provider)} subscription into {where}", + f"\ninjected your {summary.get('provider', provider)} subscription into {where}", fg=typer.colors.GREEN, bold=True, err=True, @@ -220,18 +185,17 @@ def codex_login_command( typer.secho(f" session: {sid}", fg=typer.colors.CYAN, err=True) if provider == "codex": typer.secho( - f' next: agentkit sandbox exec --sid {sid} --command "codex" # codex runs on your subscription', + f' run: agentkit sandbox exec --sid {sid} --command "codex exec -c model_provider=openai \'...\'"', fg=typer.colors.CYAN, err=True, ) - if not keep_model_config: - typer.secho( - " (switched the sandbox codex config to ChatGPT subscription auth; keep it with --keep-model-config)", - fg=typer.colors.BRIGHT_BLACK, - err=True, - ) + typer.secho( + " (config unchanged; -c model_provider=openai runs codex on your subscription)", + fg=typer.colors.BRIGHT_BLACK, + err=True, + ) typer.secho( - " note: the token is refreshed by codex/claude inside the sandbox; re-inject if the session is recreated.", + " note: the token is refreshed by codex/claude in the sandbox; re-inject if the session is recreated.", fg=typer.colors.BRIGHT_BLACK, err=True, ) diff --git a/tests/auth/test_model_login.py b/tests/auth/test_model_login.py index 1ea1c06..874c934 100644 --- a/tests/auth/test_model_login.py +++ b/tests/auth/test_model_login.py @@ -1,7 +1,7 @@ # Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. # Licensed under the Apache License, Version 2.0. -"""Unit tests for agentkit.auth.model_login (pure logic — no network).""" +"""Unit tests for agentkit.auth.model_login (pure logic - no network).""" from __future__ import annotations @@ -22,10 +22,16 @@ def _make_jwt(payload: dict) -> str: return f"{_b64url({'alg': 'RS256'})}.{_b64url(payload)}.sig" -# ── b64 / jwt helpers ──────────────────────────────────────────────────────── +def _injected_payload(cmd: str) -> dict: + """Decode the base64 blob an injection command writes, back into a dict.""" + b64 = cmd.split("printf %s '", 1)[1].split("'", 1)[0] + return json.loads(base64.b64decode(b64).decode()) + + +# b64 / jwt helpers def test_b64_roundtrip(): assert base64.b64decode(ml.b64("héllo")).decode() == "héllo" - assert "\n" not in ml.b64("x" * 1000) # single line — safe to embed in printf + assert "\n" not in ml.b64("x" * 1000) # single line - safe to embed in printf def test_decode_jwt_claims(): @@ -38,7 +44,7 @@ def test_decode_jwt_claims_malformed(): ml.decode_jwt_claims("not-a-jwt") -# ── codex auth validation + summary ────────────────────────────────────────── +# codex auth validation + summary def test_validate_codex_auth_tokens_ok(): ml.validate_codex_auth({"tokens": {"id_token": "x.y.z"}}) @@ -52,7 +58,14 @@ def test_validate_codex_auth_empty_raises(): ml.validate_codex_auth({"tokens": {}, "OPENAI_API_KEY": None}) -def test_codex_auth_summary_redacts_and_decodes_plan(): +def test_codex_auth_summary_flags_local_key_but_not_injected(): + s = ml.codex_auth_summary({"tokens": {"id_token": "a.b.c"}, "OPENAI_API_KEY": "sk-secret"}) + assert s["has_oauth_login"] is True + assert s["has_local_api_key"] is True # surfaced so the CLI can warn + assert "sk-secret" not in json.dumps(s) # the key value never appears in the summary + + +def test_codex_auth_summary_decodes_plan(): idt = _make_jwt( { "email": "u@x.com", @@ -60,93 +73,68 @@ def test_codex_auth_summary_redacts_and_decodes_plan(): ml.CODEX_OAUTH_NAMESPACE: {"chatgpt_plan_type": "pro", "chatgpt_account_id": "acc-1"}, } ) - data = { - "auth_mode": "chatgpt", - "OPENAI_API_KEY": None, - "tokens": {"id_token": idt, "refresh_token": "r", "account_id": "acc-1"}, - } - s = ml.codex_auth_summary(data) + s = ml.codex_auth_summary({"auth_mode": "chatgpt", "tokens": {"id_token": idt, "refresh_token": "r"}}) assert s["plan"] == "pro" assert s["email"] == "u@x.com" - assert s["has_refresh_token"] is True - assert s["account_id"] == "acc-1" assert s["id_token_expired"] is False - # no secret material leaks into the summary - blob = json.dumps(s) - assert idt not in blob and "r" != s.get("has_refresh_token") - - -def test_codex_auth_summary_expired_no_refresh(): - idt = _make_jwt({"email": "u@x.com", "exp": 1}) # long expired - s = ml.codex_auth_summary({"tokens": {"id_token": idt}}) - assert s["id_token_expired"] is True - assert s["has_refresh_token"] is False - - -# ── config rewrite ─────────────────────────────────────────────────────────── -def test_rewrite_drops_ark_pins_keeps_tables(): - src = "\n".join( - [ - 'model_provider = "codex"', - 'model = "ark-x"', - 'review_model = "ark-x"', - 'model_reasoning_effort = "medium"', - "", - "[model_providers.codex]", - 'env_key = "CODEX_API_KEY"', - 'model = "should-not-be-removed-inside-table"', - "", - "[tui]", - "show_tooltips = false", - ] - ) - out = ml.rewrite_codex_config_for_chatgpt(src) - assert out.startswith('preferred_auth_method = "chatgpt"\n') - # the top-level Ark pins are gone (the assignment lines, not the table name) - assert 'model_provider = "codex"' not in out - assert 'model = "ark-x"' not in out - assert 'review_model = "ark-x"' not in out - # non-pin keys & tables preserved verbatim - assert "model_reasoning_effort" in out - assert "[model_providers.codex]" in out # the provider table itself is kept - assert "[tui]" in out - assert "should-not-be-removed-inside-table" in out # `model=` inside a table is untouched - - -def test_rewrite_replaces_existing_auth_method(): - src = 'preferred_auth_method = "apikey"\nmodel_provider = "codex"\n' - out = ml.rewrite_codex_config_for_chatgpt(src) - assert out.count("preferred_auth_method") == 1 - assert '"apikey"' not in out - - -def test_minimal_config_uses_chatgpt(): - cfg = ml.minimal_chatgpt_codex_config() - assert 'preferred_auth_method = "chatgpt"' in cfg - assert "trust_level" in cfg - - -# ── injection command ──────────────────────────────────────────────────────── -def test_build_codex_injection_command(): - auth = '{"tokens":{"id_token":"x"}}' - cmd = ml.build_codex_injection_command(auth_json=auth, config_toml="cfg") - assert ml.b64(auth) in cmd - assert ml.b64("cfg") in cmd + + +# security: OAuth-only sanitization (never inject a long-lived API key) +def test_sanitize_strips_api_key_keeps_oauth(): + raw = { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": "sk-LONG-LIVED-SECRET", + "tokens": {"id_token": "i", "access_token": "a", "refresh_token": "r", "account_id": "acc"}, + "last_refresh": "2026-06-30", + } + safe = ml.sanitize_codex_auth_for_injection(raw) + assert safe["OPENAI_API_KEY"] is None + assert "sk-LONG-LIVED-SECRET" not in json.dumps(safe) # the key never survives + assert safe["tokens"] == raw["tokens"] # OAuth tokens carried through + assert safe["auth_mode"] == "chatgpt" + + +def test_sanitize_refuses_apikey_only(): + with pytest.raises(ml.ModelLoginError): + ml.sanitize_codex_auth_for_injection({"OPENAI_API_KEY": "sk-x", "tokens": None}) + + +def test_sanitize_drops_unknown_top_level_fields(): + raw = { + "tokens": {"id_token": "i"}, + "OPENAI_API_KEY": "sk-x", + "some_other_secret": "leak-me", + } + safe = ml.sanitize_codex_auth_for_injection(raw) + assert set(safe.keys()) == {"OPENAI_API_KEY", "auth_mode", "tokens", "last_refresh"} + assert "leak-me" not in json.dumps(safe) + + +def test_build_codex_injection_command_injects_oauth_only(): + raw = {"OPENAI_API_KEY": "sk-secret", "tokens": {"id_token": "i", "refresh_token": "r"}} + cmd = ml.build_codex_injection_command(auth_data=raw) assert ml.CODEX_INJECT_MARKER in cmd assert 'chmod 600 "$CH/auth.json"' in cmd - assert '${CODEX_HOME:-$HOME/.codex}' in cmd + assert "${CODEX_HOME:-$HOME/.codex}" in cmd + assert "sk-secret" not in cmd and "sk-secret" not in base64.b64decode( + cmd.split("printf %s '", 1)[1].split("'", 1)[0] + ).decode() + payload = _injected_payload(cmd) + assert payload["OPENAI_API_KEY"] is None + assert payload["tokens"]["id_token"] == "i" -def test_build_codex_injection_command_no_config(): - cmd = ml.build_codex_injection_command(auth_json="{}") - assert "config.toml" not in cmd +def test_build_codex_injection_command_refuses_apikey_only(): + with pytest.raises(ml.ModelLoginError): + ml.build_codex_injection_command(auth_data={"OPENAI_API_KEY": "sk-x"}) -def test_read_codex_config_command(): - assert "config.toml" in ml.read_codex_config_command() +def test_build_codex_injection_command_never_touches_config(): + cmd = ml.build_codex_injection_command(auth_data={"tokens": {"id_token": "i"}}) + assert "config.toml" not in cmd # inject-only: config left to the platform / exec-time -c -# ── local resolution ───────────────────────────────────────────────────────── +# local resolution def test_read_codex_auth_missing(tmp_path): with pytest.raises(ml.ModelLoginError): ml.read_codex_auth(tmp_path / "nope.json") @@ -179,7 +167,7 @@ def fake_login(*, codex_home, timeout): assert data["tokens"]["id_token"] == "x" -# ── claude ─────────────────────────────────────────────────────────────────── +# claude def test_claude_read_validate_summary(tmp_path): creds = { "claudeAiOauth": { @@ -210,8 +198,25 @@ def test_claude_missing_file_raises(tmp_path): ml.read_claude_creds(creds_file=str(tmp_path / "nope.json")) +def test_claude_sanitize_drops_non_oauth_fields(): + raw = { + "claudeAiOauth": {"accessToken": "at", "refreshToken": "rt"}, + "primaryApiKey": "sk-ant-LONG-LIVED", + } + safe = ml.sanitize_claude_creds_for_injection(raw) + assert set(safe.keys()) == {"claudeAiOauth"} + assert "sk-ant-LONG-LIVED" not in json.dumps(safe) + + +def test_claude_sanitize_refuses_without_oauth(): + with pytest.raises(ml.ModelLoginError): + ml.sanitize_claude_creds_for_injection({"primaryApiKey": "sk-ant-x"}) + + def test_build_claude_injection_command(): - cmd = ml.build_claude_injection_command(creds_json='{"claudeAiOauth":{"accessToken":"x"}}') + raw = {"claudeAiOauth": {"accessToken": "at"}, "primaryApiKey": "sk-ant-x"} + cmd = ml.build_claude_injection_command(creds_data=raw) assert ml.CLAUDE_INJECT_MARKER in cmd - assert '.credentials.json' in cmd - assert 'chmod 600' in cmd + assert ".credentials.json" in cmd + assert "chmod 600" in cmd + assert "sk-ant-x" not in base64.b64decode(cmd.split("printf %s '", 1)[1].split("'", 1)[0]).decode() diff --git a/tests/toolkit/cli/test_codex_login_cli.py b/tests/toolkit/cli/test_codex_login_cli.py index 21586f1..209b5c7 100644 --- a/tests/toolkit/cli/test_codex_login_cli.py +++ b/tests/toolkit/cli/test_codex_login_cli.py @@ -4,7 +4,8 @@ """CLI tests for `agentkit sandbox codex-login` (the model-subscription injector). The local credential is supplied via --auth-file; the sandbox session + shell exec are mocked so -no network/login happens. We assert the injected payload + the ChatGPT config switch. +no network/login happens. We assert the feature injects ONLY the OAuth token (never a long-lived +API key) and leaves the sandbox config untouched. """ from __future__ import annotations @@ -29,8 +30,6 @@ def _patch_sandbox(monkeypatch, captured): def fake_exec(session, command, quiet_errors=False): captured.append(command) - if "config.toml" in command and command.strip().startswith("cat"): - return {"data": {"output": 'model_provider = "codex"\nmodel = "ark"\n[tui]\nshow_tooltips = false\n', "exit_code": 0}} if ml.CODEX_INJECT_MARKER in command: return {"data": {"output": f"{ml.CODEX_INJECT_MARKER} /home/gem/.codex", "exit_code": 0}} if ml.CLAUDE_INJECT_MARKER in command: @@ -40,38 +39,45 @@ def fake_exec(session, command, quiet_errors=False): monkeypatch.setattr(cml, "_exec_shell_command", fake_exec) -def test_codex_login_injects_and_switches_config(tmp_path, monkeypatch): +def _injected_payload(cmd: str) -> dict: + b64 = cmd.split("printf %s '", 1)[1].split("'", 1)[0] + return json.loads(base64.b64decode(b64).decode()) + + +def test_codex_login_injects_oauth_only_and_leaves_config(tmp_path, monkeypatch): auth = tmp_path / "auth.json" - auth.write_text(json.dumps({"auth_mode": "chatgpt", "tokens": {"id_token": "a.b.c", "refresh_token": "r"}})) + auth.write_text(json.dumps({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": "sk-LONG-LIVED-SECRET", # must never reach the sandbox + "tokens": {"id_token": "a.b.c", "refresh_token": "r", "account_id": "acc"}, + })) captured: list[str] = [] _patch_sandbox(monkeypatch, captured) result = runner.invoke(app, ["sandbox", "codex-login", "--auth-file", str(auth)]) assert result.exit_code == 0, result.output - # read the current sandbox config, then inject auth.json + a ChatGPT-mode config - assert any(c.strip().startswith("cat") and "config.toml" in c for c in captured) + # exactly one exec: the injection (no config read/write) + assert not any("config.toml" in c for c in captured) inject = next(c for c in captured if ml.CODEX_INJECT_MARKER in c) - assert ml.b64(json.dumps(json.loads(auth.read_text()), ensure_ascii=False)) in inject - cfg_b64 = inject.split("printf %s '")[2].split("'")[0] - pushed_cfg = base64.b64decode(cfg_b64).decode() - assert pushed_cfg.startswith('preferred_auth_method = "chatgpt"') - assert 'model_provider = "codex"' not in pushed_cfg - assert "[tui]" in pushed_cfg - assert "s-1" in result.output # session id surfaced for reuse + assert "sk-LONG-LIVED-SECRET" not in inject + payload = _injected_payload(inject) + assert payload["OPENAI_API_KEY"] is None # key stripped + assert payload["tokens"]["id_token"] == "a.b.c" # OAuth carried + assert "s-1" in result.output # session id surfaced + assert "sk-LONG-LIVED-SECRET" not in result.output # never printed -def test_codex_login_keep_model_config(tmp_path, monkeypatch): +def test_codex_login_refuses_apikey_only(tmp_path, monkeypatch): auth = tmp_path / "auth.json" - auth.write_text(json.dumps({"tokens": {"id_token": "a.b.c"}})) + auth.write_text(json.dumps({"OPENAI_API_KEY": "sk-x", "tokens": None})) captured: list[str] = [] _patch_sandbox(monkeypatch, captured) - result = runner.invoke(app, ["sandbox", "codex-login", "--auth-file", str(auth), "--keep-model-config"]) - assert result.exit_code == 0, result.output - assert not any(c.strip().startswith("cat") for c in captured) # no config read - inject = next(c for c in captured if ml.CODEX_INJECT_MARKER in c) - assert "config.toml" not in inject + result = runner.invoke(app, ["sandbox", "codex-login", "--auth-file", str(auth)]) + assert result.exit_code != 0 + assert "OAuth" in result.output or "refusing" in result.output + assert not captured # nothing injected def test_codex_login_dry_run_no_session(tmp_path, monkeypatch): @@ -96,9 +102,12 @@ def test_codex_login_missing_auth_file(monkeypatch): assert "not found" in result.output -def test_claude_login_injects(tmp_path, monkeypatch): +def test_claude_login_injects_oauth_only(tmp_path, monkeypatch): creds = tmp_path / ".credentials.json" - creds.write_text(json.dumps({"claudeAiOauth": {"accessToken": "at", "refreshToken": "rt", "subscriptionType": "max"}})) + creds.write_text(json.dumps({ + "claudeAiOauth": {"accessToken": "at", "refreshToken": "rt", "subscriptionType": "max"}, + "primaryApiKey": "sk-ant-LONG-LIVED", + })) captured: list[str] = [] _patch_sandbox(monkeypatch, captured) @@ -106,6 +115,9 @@ def test_claude_login_injects(tmp_path, monkeypatch): assert result.exit_code == 0, result.output inject = next(c for c in captured if ml.CLAUDE_INJECT_MARKER in c) assert ".credentials.json" in inject + payload = _injected_payload(inject) + assert set(payload.keys()) == {"claudeAiOauth"} # only OAuth + assert "sk-ant-LONG-LIVED" not in inject def test_unknown_provider(monkeypatch):