From 6c52a70078d8f9a1a7f1cc3568c7433f4327c152 Mon Sep 17 00:00:00 2001 From: didericis-codex Date: Fri, 29 May 2026 03:46:15 -0400 Subject: [PATCH] fix(codex): provision dummy user auth state --- README.md | 16 +-- bot_bottle/backend/__init__.py | 6 + bot_bottle/backend/docker/backend.py | 4 + bot_bottle/backend/docker/bottle_plan.py | 1 + bot_bottle/backend/docker/prepare.py | 5 + .../backend/docker/provision/provider_auth.py | 43 ++++++++ bot_bottle/backend/smolmachines/backend.py | 6 + .../backend/smolmachines/bottle_plan.py | 1 + bot_bottle/backend/smolmachines/prepare.py | 5 + .../smolmachines/provision/provider_auth.py | 30 +++++ bot_bottle/codex_auth.py | 93 ++++++++++++++-- .../0029-codex-host-credentials-egress.md | 38 ++++--- tests/unit/test_codex_auth.py | 46 +++++++- .../test_docker_provision_provider_auth.py | 104 ++++++++++++++++++ tests/unit/test_smolmachines_provision.py | 39 +++++++ 15 files changed, 406 insertions(+), 31 deletions(-) create mode 100644 bot_bottle/backend/docker/provision/provider_auth.py create mode 100644 bot_bottle/backend/smolmachines/provision/provider_auth.py create mode 100644 tests/unit/test_docker_provision_provider_auth.py diff --git a/README.md b/README.md index d688566..0d2a034 100644 --- a/README.md +++ b/README.md @@ -369,13 +369,15 @@ egress: ``` Run `codex login --device-auth` on the host before launch. The -launcher reads only `tokens.access_token` from the host's -`~/.codex/auth.json`, verifies it is fresh ChatGPT auth, and passes it -to the sidecar's `EGRESS_TOKEN_N` env slot. The agent container does -not receive `auth.json`, refresh tokens, access-token env vars, or -`OPENAI_API_KEY`. The effective egress table automatically adds or -upgrades `api.openai.com` and `chatgpt.com` to authenticated routes -when `forward_host_credentials` is true. +launcher reads `tokens.access_token` from the host's +`~/.codex/auth.json`, verifies it is fresh user/device auth, and passes +it to the sidecar's `EGRESS_TOKEN_N` env slot. The agent container gets +a dummy `~/.codex/auth.json` that preserves the host auth-mode shape +but replaces credential values with placeholders, so Codex chooses the +user/device auth path without receiving real access tokens, refresh +tokens, or `OPENAI_API_KEY`. The effective egress table automatically +adds or upgrades `api.openai.com` and `chatgpt.com` to authenticated +routes when `forward_host_credentials` is true. The built-in Codex template uses `Dockerfile.codex`; set `agent_provider.dockerfile` to build the agent from a custom Dockerfile diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index bd680de..c55faac 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -286,6 +286,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): intercepted without per-tool reconfiguration.""" self.provision_ca(plan, target) prompt_path = self.provision_prompt(plan, target) + self.provision_provider_auth(plan, target) self.provision_skills(plan, target) self.provision_git(plan, target) self.provision_supervise(plan, target) @@ -300,6 +301,11 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): backend overrides to docker-cp the cert in and run `update-ca-certificates`.""" + def provision_provider_auth(self, plan: PlanT, target: str) -> None: + """Install non-secret provider auth marker files into the agent + home when a provider needs them to select the right auth mode. + The default is no-op.""" + @abstractmethod def provision_prompt(self, plan: PlanT, target: str) -> str | None: """Copy the prompt file into the running bottle. Returns the diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index 195f924..23f7b97 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -29,6 +29,7 @@ from .bottle_plan import DockerBottlePlan from .provision import ca as _ca from .provision import git as _git from .provision import prompt as _prompt +from .provision import provider_auth as _provider_auth from .provision import skills as _skills from .provision import supervise as _supervise_prov @@ -62,6 +63,9 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None: return _prompt.provision_prompt(plan, target) + def provision_provider_auth(self, plan: DockerBottlePlan, target: str) -> None: + _provider_auth.provision_provider_auth(plan, target) + def provision_skills(self, plan: DockerBottlePlan, target: str) -> None: _skills.provision_skills(plan, target) diff --git a/bot_bottle/backend/docker/bottle_plan.py b/bot_bottle/backend/docker/bottle_plan.py index 8e49c07..9c68eaf 100644 --- a/bot_bottle/backend/docker/bottle_plan.py +++ b/bot_bottle/backend/docker/bottle_plan.py @@ -55,6 +55,7 @@ class DockerBottlePlan(BottlePlan): agent_command: str = "claude" agent_prompt_mode: PromptMode = "append_file" agent_provider_template: str = "claude" + codex_auth_file: Path | None = None def print(self, *, remote_control: bool) -> None: """Render the y/N preflight summary to stderr — compact form diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py index c2c5943..760ca2f 100644 --- a/bot_bottle/backend/docker/prepare.py +++ b/bot_bottle/backend/docker/prepare.py @@ -15,6 +15,7 @@ from datetime import datetime, timezone from pathlib import Path from ...agent_provider import runtime_for +from ...codex_auth import write_codex_dummy_auth_file from ...egress import Egress from ...env import ResolvedEnv, resolve_env from ...git_gate import GitGate @@ -155,6 +156,7 @@ def resolve_plan( agent_dir.mkdir(parents=True, exist_ok=True) env_file = agent_dir / "agent.env" prompt_file = agent_dir / "prompt.txt" + codex_auth_file = agent_dir / "codex-auth.json" prompt_file.write_text("") prompt_file.chmod(0o600) @@ -219,6 +221,8 @@ def resolve_plan( # error reporting) that egress can't gate by auth. forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1") + if provider.forward_host_credentials: + write_codex_dummy_auth_file(codex_auth_file, dict(os.environ)) _write_env_file(resolved, env_file) prompt_file.write_text(agent.prompt) @@ -245,6 +249,7 @@ def resolve_plan( agent_command=provider_runtime.command, agent_prompt_mode=provider_runtime.prompt_mode, agent_provider_template=provider.template, + codex_auth_file=codex_auth_file if provider.forward_host_credentials else None, ) diff --git a/bot_bottle/backend/docker/provision/provider_auth.py b/bot_bottle/backend/docker/provision/provider_auth.py new file mode 100644 index 0000000..e992e87 --- /dev/null +++ b/bot_bottle/backend/docker/provision/provider_auth.py @@ -0,0 +1,43 @@ +"""Provision non-secret provider auth markers into a Docker bottle.""" + +from __future__ import annotations + +import os +import subprocess + +from ..bottle_plan import DockerBottlePlan + + +def provision_provider_auth(plan: DockerBottlePlan, target: str) -> None: + """Copy a dummy Codex auth marker when host credentials are + forwarded through egress. + + The file contains no real access or refresh token values; it only + nudges Codex into the same user/device auth branch as the host. + """ + if not plan.codex_auth_file: + return + container_home = os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node") + auth_dir = f"{container_home}/.codex" + auth_path = f"{auth_dir}/auth.json" + + subprocess.run( + ["docker", "exec", "-u", "0", target, "mkdir", "-p", auth_dir], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "cp", str(plan.codex_auth_file), f"{target}:{auth_path}"], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", target, "chown", "node:node", auth_path], + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + ["docker", "exec", "-u", "0", target, "chmod", "600", auth_path], + stdout=subprocess.DEVNULL, + check=True, + ) diff --git a/bot_bottle/backend/smolmachines/backend.py b/bot_bottle/backend/smolmachines/backend.py index b1d054a..bc3ab65 100644 --- a/bot_bottle/backend/smolmachines/backend.py +++ b/bot_bottle/backend/smolmachines/backend.py @@ -19,6 +19,7 @@ from .bottle_plan import SmolmachinesBottlePlan from .provision import ca as _ca from .provision import git as _git from .provision import prompt as _prompt +from .provision import provider_auth as _provider_auth from .provision import skills as _skills from .provision import supervise as _supervise @@ -61,6 +62,11 @@ class SmolmachinesBottleBackend( ) -> str | None: return _prompt.provision_prompt(plan, target) + def provision_provider_auth( + self, plan: SmolmachinesBottlePlan, target: str + ) -> None: + _provider_auth.provision_provider_auth(plan, target) + def provision_skills( self, plan: SmolmachinesBottlePlan, target: str ) -> None: diff --git a/bot_bottle/backend/smolmachines/bottle_plan.py b/bot_bottle/backend/smolmachines/bottle_plan.py index e90714a..a30e752 100644 --- a/bot_bottle/backend/smolmachines/bottle_plan.py +++ b/bot_bottle/backend/smolmachines/bottle_plan.py @@ -97,6 +97,7 @@ class SmolmachinesBottlePlan(BottlePlan): agent_prompt_mode: PromptMode = "append_file" agent_provider_template: str = "claude" agent_dockerfile_path: str = "" + codex_auth_file: Path | None = None def print(self, *, remote_control: bool) -> None: """Compact y/N preflight. Same shape as the Docker diff --git a/bot_bottle/backend/smolmachines/prepare.py b/bot_bottle/backend/smolmachines/prepare.py index 91a5d88..5a78f5e 100644 --- a/bot_bottle/backend/smolmachines/prepare.py +++ b/bot_bottle/backend/smolmachines/prepare.py @@ -16,6 +16,7 @@ from pathlib import Path from ...agent_provider import runtime_for from ...backend import BottleSpec +from ...codex_auth import write_codex_dummy_auth_file from ...backend.docker.bottle_state import ( BottleMetadata, agent_state_dir, @@ -144,9 +145,12 @@ def resolve_plan( agent_dir = agent_state_dir(slug) agent_dir.mkdir(parents=True, exist_ok=True) prompt_file = agent_dir / "prompt.txt" + codex_auth_file = agent_dir / "codex-auth.json" agent = manifest.agents[spec.agent_name] prompt_file.write_text(agent.prompt or "") prompt_file.chmod(0o600) + if provider.forward_host_credentials: + write_codex_dummy_auth_file(codex_auth_file, dict(os.environ)) machine_name = f"bot-bottle-{slug}" # Stash the agent image ref — `launch.launch` runs the @@ -182,6 +186,7 @@ def resolve_plan( agent_prompt_mode=provider_runtime.prompt_mode, agent_provider_template=provider.template, agent_dockerfile_path=agent_dockerfile_path, + codex_auth_file=codex_auth_file if provider.forward_host_credentials else None, ) diff --git a/bot_bottle/backend/smolmachines/provision/provider_auth.py b/bot_bottle/backend/smolmachines/provision/provider_auth.py new file mode 100644 index 0000000..8bdcc2f --- /dev/null +++ b/bot_bottle/backend/smolmachines/provision/provider_auth.py @@ -0,0 +1,30 @@ +"""Provision non-secret provider auth markers into a smolmachines bottle.""" + +from __future__ import annotations + +import os + +from .. import smolvm as _smolvm +from ..bottle_plan import SmolmachinesBottlePlan + + +_DEFAULT_GUEST_HOME = "/home/node" + + +def provision_provider_auth(plan: SmolmachinesBottlePlan, target: str) -> None: + """Copy a dummy Codex auth marker when host credentials are + forwarded through egress. + + The real host access token remains in the egress bundle env; this + file only selects Codex's user/device auth code path. + """ + if not plan.codex_auth_file: + return + guest_home = os.environ.get("BOT_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME) + auth_dir = f"{guest_home}/.codex" + auth_path = f"{auth_dir}/auth.json" + + _smolvm.machine_exec(target, ["mkdir", "-p", auth_dir]) + _smolvm.machine_cp(str(plan.codex_auth_file), f"{target}:{auth_path}") + _smolvm.machine_exec(target, ["chown", "node:node", auth_path]) + _smolvm.machine_exec(target, ["chmod", "600", auth_path]) diff --git a/bot_bottle/codex_auth.py b/bot_bottle/codex_auth.py index 707c562..6fba5ba 100644 --- a/bot_bottle/codex_auth.py +++ b/bot_bottle/codex_auth.py @@ -10,6 +10,7 @@ from __future__ import annotations import base64 import json import os +from copy import deepcopy from datetime import datetime, timezone from pathlib import Path @@ -37,16 +38,12 @@ def codex_host_access_token( "Run `codex login --device-auth` on the host or disable " "agent_provider.forward_host_credentials." ) - try: - raw = json.loads(path.read_text()) - except (OSError, json.JSONDecodeError) as e: - die(f"codex host credentials: could not read valid JSON at {path}: {e}") - if not isinstance(raw, dict): - die(f"codex host credentials: {path} must contain a JSON object") + raw = _read_auth_object(path) - if raw.get("auth_mode") != "chatgpt": + auth_mode = raw.get("auth_mode") + if not isinstance(auth_mode, str) or auth_mode == "api_key": die( - "codex host credentials: host Codex auth is not ChatGPT/device " + "codex host credentials: host Codex auth is not user/device " "auth. Run `codex login --device-auth` on the host." ) @@ -72,6 +69,79 @@ def codex_host_access_token( return access +def codex_dummy_auth_json( + host_env: dict[str, str] | None = None, + *, + now: datetime | None = None, +) -> str: + """Return a non-secret `auth.json` that keeps Codex in the host's + auth branch while egress owns the real bearer token.""" + path = codex_auth_path(host_env) + codex_host_access_token(host_env, now=now) + raw = _read_auth_object(path) + dummy = _redact_codex_auth(deepcopy(raw), now=now) + return json.dumps(dummy, indent=2, sort_keys=True) + "\n" + + +def write_codex_dummy_auth_file( + path: Path, + host_env: dict[str, str] | None = None, + *, + now: datetime | None = None, +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(codex_dummy_auth_json(host_env, now=now)) + path.chmod(0o600) + + +def _read_auth_object(path: Path) -> dict: + try: + raw = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as e: + die(f"codex host credentials: could not read valid JSON at {path}: {e}") + if not isinstance(raw, dict): + die(f"codex host credentials: {path} must contain a JSON object") + return raw + + +def _dummy_jwt(now: datetime | None = None) -> str: + check_now = now or datetime.now(timezone.utc) + exp = int(check_now.timestamp()) + 3600 + + def enc(obj: dict) -> str: + raw = json.dumps(obj, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + return ( + f"{enc({'alg': 'none', 'typ': 'JWT'})}." + f"{enc({'exp': exp, 'sub': 'bot-bottle-placeholder'})}." + "placeholder" + ) + + +def _redact_codex_auth(value: object, *, now: datetime | None = None) -> object: + if isinstance(value, dict): + out: dict[str, object] = {} + for key, inner in value.items(): + lower = key.lower() + if lower == "openai_api_key": + out[key] = None + elif lower == "tokens": + out[key] = _redact_codex_auth(inner, now=now) + elif lower in {"access_token", "id_token"}: + out[key] = _dummy_jwt(now) + elif "token" in lower or "secret" in lower or lower.endswith("_key"): + out[key] = "bot-bottle-placeholder" + elif lower in {"account_id", "user_id", "email"}: + out[key] = "bot-bottle-placeholder" + else: + out[key] = _redact_codex_auth(inner, now=now) + return out + if isinstance(value, list): + return [_redact_codex_auth(v, now=now) for v in value] + return value + + def _jwt_exp(token: str) -> datetime | None: parts = token.split(".") if len(parts) < 2: @@ -93,4 +163,9 @@ def _b64url_decode(value: str) -> str: return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8") -__all__ = ["codex_auth_path", "codex_host_access_token"] +__all__ = [ + "codex_auth_path", + "codex_dummy_auth_json", + "codex_host_access_token", + "write_codex_dummy_auth_file", +] diff --git a/docs/prds/0029-codex-host-credentials-egress.md b/docs/prds/0029-codex-host-credentials-egress.md index b812d15..8865e02 100644 --- a/docs/prds/0029-codex-host-credentials-egress.md +++ b/docs/prds/0029-codex-host-credentials-egress.md @@ -21,10 +21,11 @@ routes that declare an egress-owned token. Bare `api.openai.com` or `chatgpt.com` routes therefore forward Codex requests without the ChatGPT bearer token. -Copying `~/.codex/auth.json` into the agent would solve auth but would -also put access and refresh material inside the agent sandbox. That cuts -against bot-bottle's credential minimization model: provider credentials -should live in the sidecar boundary when possible, not in the agent. +Copying the host `~/.codex/auth.json` into the agent would solve auth +mode detection but would also put access and refresh material inside the +agent sandbox. That cuts against bot-bottle's credential minimization +model: provider credentials should live in the sidecar boundary when +possible, not in the agent. ## Goals / Success Criteria @@ -33,11 +34,14 @@ should live in the sidecar boundary when possible, not in the agent. - Host credential forwarding happens only when the bottle declares `agent_provider.forward_host_credentials: true`. - The agent container does not receive `OPENAI_API_KEY`, - `CODEX_ACCESS_TOKEN`, `tokens.access_token`, `tokens.refresh_token`, - or `auth.json`. + `CODEX_ACCESS_TOKEN`, or real `tokens.access_token` / + `tokens.refresh_token` values. +- The agent container receives only a dummy Codex `auth.json` that + preserves the host auth-mode shape and replaces credential values + with placeholders. - Egress route files remain non-secret: they contain only host/path/auth slot metadata, never token values. -- Missing, non-ChatGPT, malformed, or expired host Codex auth fails +- Missing, API-key, malformed, or expired host Codex auth fails launch with a clear operator-facing message. - Existing Claude OAuth placeholder behavior remains unchanged. @@ -46,7 +50,7 @@ should live in the sidecar boundary when possible, not in the agent. - Refreshing Codex tokens in the sidecar. The first cut reads the host's current access token at launch; operators can restart after host Codex refreshes auth. -- Copying host `~/.codex/auth.json` into the agent. +- Copying host `~/.codex/auth.json` credentials into the agent. - Allowing arbitrary host credential forwarding. This PRD covers Codex ChatGPT/device-login credentials only. - Hot-applying new authenticated Codex routes to an existing running @@ -62,9 +66,11 @@ should live in the sidecar boundary when possible, not in the agent. - Support the flag for `agent_provider.template: codex`. - Read host Codex auth from `$CODEX_HOME/auth.json` when `CODEX_HOME` is set, otherwise from `~/.codex/auth.json`. -- Extract only `tokens.access_token`. -- Validate that `auth_mode` is `chatgpt` and the access token is present, - JWT-shaped, and not expired. +- Extract only `tokens.access_token` for egress injection. +- Generate a dummy agent-side `auth.json` from the host auth file's + mode and key shape, without copying real token values. +- Validate that host auth is not API-key mode and the access token is + present, JWT-shaped, and not expired. - Add or upgrade `api.openai.com` and `chatgpt.com` egress routes to inject that access token via a shared `EGRESS_TOKEN_N` sidecar env slot. @@ -100,7 +106,7 @@ At prepare/launch time, when the flag is enabled for Codex: 1. Resolve the host Codex home directory from `$CODEX_HOME`, falling back to `~/.codex`. 2. Parse `auth.json`. -3. Require `auth_mode == "chatgpt"`. +3. Require user/device auth mode rather than API-key auth. 4. Require a non-empty `tokens.access_token`. 5. Parse the JWT payload enough to require an `exp` claim in the future. 6. Return only the access token value to the launch path. @@ -141,6 +147,7 @@ environment for that `EGRESS_TOKEN_N` slot. It must not be written to flowchart LR H["Host ~/.codex/auth.json"] --> L["bot-bottle launch"] L -->|access token only| S["egress sidecar env"] + L -->|dummy auth.json only| A A["Codex agent"] -->|HTTPS via proxy, auth stripped| E["egress"] E -->|Bearer injected from env| C["api.openai.com / chatgpt.com"] ``` @@ -156,9 +163,12 @@ flowchart LR 4. **Effective egress route.** Add/upgrade the Codex API routes when the flag is enabled, and add tests for bare route upgrade, missing-route insertion, and authenticated-route conflict. -5. **Launch wiring.** Pass the host access token into the egress sidecar +5. **Agent auth marker.** Provision a dummy Codex `auth.json` into the + agent home so Codex selects the host's user/device auth branch while + real credentials stay in egress. +6. **Launch wiring.** Pass the host access token into the egress sidecar env for Docker and smolmachines without exposing it to the agent. -6. **Docs and tests.** Update README examples and run the unit suite. +7. **Docs and tests.** Update README examples and run the unit suite. ## Open questions diff --git a/tests/unit/test_codex_auth.py b/tests/unit/test_codex_auth.py index 47dd461..8bb9620 100644 --- a/tests/unit/test_codex_auth.py +++ b/tests/unit/test_codex_auth.py @@ -9,7 +9,11 @@ import unittest from datetime import datetime, timezone from pathlib import Path -from bot_bottle.codex_auth import codex_auth_path, codex_host_access_token +from bot_bottle.codex_auth import ( + codex_auth_path, + codex_dummy_auth_json, + codex_host_access_token, +) from bot_bottle.log import Die @@ -59,6 +63,15 @@ class TestCodexHostAccessToken(unittest.TestCase): with self.assertRaises(Die): codex_host_access_token({"CODEX_HOME": str(self.home)}) + def test_user_auth_mode_is_allowed(self): + token = _jwt(2000000000) + self._write({"auth_mode": "user", "tokens": {"access_token": token}}) + out = codex_host_access_token( + {"CODEX_HOME": str(self.home)}, + now=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + self.assertEqual(token, out) + def test_expired_token_dies(self): self._write({ "auth_mode": "chatgpt", @@ -78,6 +91,37 @@ class TestCodexHostAccessToken(unittest.TestCase): with self.assertRaises(Die): codex_host_access_token({"CODEX_HOME": str(self.home)}) + def test_dummy_auth_preserves_mode_and_redacts_tokens(self): + access = _jwt(2000000000) + refresh = "host-refresh-token" + self._write({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": None, + "tokens": { + "access_token": access, + "id_token": _jwt(2000000000), + "refresh_token": refresh, + "account_id": "acct-host", + }, + "last_refresh": "2026-05-29T00:00:00.000Z", + }) + dummy = json.loads(codex_dummy_auth_json( + {"CODEX_HOME": str(self.home)}, + now=datetime(2026, 1, 1, tzinfo=timezone.utc), + )) + self.assertEqual("chatgpt", dummy["auth_mode"]) + self.assertIsNone(dummy["OPENAI_API_KEY"]) + self.assertNotEqual(access, dummy["tokens"]["access_token"]) + self.assertNotEqual(refresh, dummy["tokens"]["refresh_token"]) + self.assertEqual("bot-bottle-placeholder", dummy["tokens"]["refresh_token"]) + self.assertEqual("bot-bottle-placeholder", dummy["tokens"]["account_id"]) + self.assertIsNotNone( + codex_host_access_token( + {"CODEX_HOME": str(self.home)}, + now=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_docker_provision_provider_auth.py b/tests/unit/test_docker_provision_provider_auth.py new file mode 100644 index 0000000..2ac2e70 --- /dev/null +++ b/tests/unit/test_docker_provision_provider_auth.py @@ -0,0 +1,104 @@ +"""Unit: docker provider auth marker provisioning.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from unittest.mock import patch + +from bot_bottle.backend import BottleSpec +from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan +from bot_bottle.backend.docker.provision import provider_auth as _provider_auth +from bot_bottle.egress import EgressPlan +from bot_bottle.git_gate import GitGatePlan +from bot_bottle.manifest import Manifest +from bot_bottle.pipelock import PipelockProxyPlan + + +def _plan(*, codex_auth_file: Path | None = None) -> DockerBottlePlan: + manifest = Manifest.from_json_obj({ + "bottles": {"dev": {"agent_provider": {"template": "codex"}}}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + return DockerBottlePlan( + spec=BottleSpec( + manifest=manifest, + agent_name="demo", + copy_cwd=False, + user_cwd="/tmp/x", + ), + stage_dir=Path("/tmp/stage"), + slug="demo-abc12", + container_name="bot-bottle-demo-abc12", + container_name_pinned=False, + image="bot-bottle-codex:latest", + derived_image="", + runtime_image="bot-bottle-codex:latest", + dockerfile_path="", + env_file=Path("/tmp/agent.env"), + forwarded_env={}, + prompt_file=Path("/tmp/prompt.txt"), + proxy_plan=PipelockProxyPlan( + yaml_path=Path("/tmp/pipelock.yaml"), + slug="demo-abc12", + ), + git_gate_plan=GitGatePlan( + slug="demo-abc12", + entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), + hook_script=Path("/tmp/git-gate-hook"), + access_hook_script=Path("/tmp/git-gate-access-hook"), + upstreams=(), + ), + egress_plan=EgressPlan( + slug="demo-abc12", + routes_path=Path("/tmp/routes.yaml"), + routes=(), + token_env_map={}, + ), + supervise_plan=None, + use_runsc=False, + agent_command="codex", + agent_provider_template="codex", + codex_auth_file=codex_auth_file, + ) + + +class TestProvisionProviderAuth(unittest.TestCase): + def test_noop_without_codex_auth_file(self): + with patch.object(_provider_auth.subprocess, "run") as run: + _provider_auth.provision_provider_auth( + _plan(), "bot-bottle-demo-abc12", + ) + self.assertEqual(0, run.call_count) + + def test_copies_dummy_auth_json_to_codex_home(self): + with patch.object(_provider_auth.subprocess, "run") as run: + _provider_auth.provision_provider_auth( + _plan(codex_auth_file=Path("/tmp/codex-auth.json")), + "bot-bottle-demo-abc12", + ) + argvs = [call.args[0] for call in run.call_args_list] + self.assertIn( + ["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", + "mkdir", "-p", "/home/node/.codex"], + argvs, + ) + self.assertIn( + ["docker", "cp", "/tmp/codex-auth.json", + "bot-bottle-demo-abc12:/home/node/.codex/auth.json"], + argvs, + ) + self.assertIn( + ["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", + "chown", "node:node", "/home/node/.codex/auth.json"], + argvs, + ) + self.assertIn( + ["docker", "exec", "-u", "0", "bot-bottle-demo-abc12", + "chmod", "600", "/home/node/.codex/auth.json"], + argvs, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index bc75086..7984256 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -20,6 +20,7 @@ from bot_bottle.backend.smolmachines.provision import ( ca as _ca, git as _git, prompt as _prompt, + provider_auth as _provider_auth, skills as _skills, supervise as _supervise, ) @@ -53,6 +54,7 @@ def _plan( bundle_ip: str = "192.168.50.2", agent_git_gate_host: str = "127.0.0.1:55555", agent_supervise_url: str = "http://127.0.0.1:55556/", + codex_auth_file: Path | None = None, ) -> SmolmachinesBottlePlan: bottle_json: dict = {} git_json: dict = {} @@ -127,6 +129,7 @@ def _plan( supervise_plan=supervise_plan, agent_git_gate_host=agent_git_gate_host, agent_supervise_url=agent_supervise_url, + codex_auth_file=codex_auth_file, ) @@ -187,6 +190,42 @@ class TestProvisionPrompt(unittest.TestCase): ) +class TestProvisionProviderAuth(unittest.TestCase): + def test_noop_without_codex_auth_file(self): + with patch( + "bot_bottle.backend.smolmachines.provision.provider_auth._smolvm.machine_cp" + ) as cp, patch( + "bot_bottle.backend.smolmachines.provision.provider_auth._smolvm.machine_exec" + ) as ex: + _provider_auth.provision_provider_auth( + _plan(), "bot-bottle-demo-abc12", + ) + self.assertEqual(0, cp.call_count) + self.assertEqual(0, ex.call_count) + + def test_copies_dummy_auth_json_to_codex_home(self): + with patch( + "bot_bottle.backend.smolmachines.provision.provider_auth._smolvm.machine_cp" + ) as cp, patch( + "bot_bottle.backend.smolmachines.provision.provider_auth._smolvm.machine_exec" + ) as ex: + _provider_auth.provision_provider_auth( + _plan(codex_auth_file=Path("/tmp/codex-auth.json")), + "bot-bottle-demo-abc12", + ) + cp.assert_called_once_with( + "/tmp/codex-auth.json", + "bot-bottle-demo-abc12:/home/node/.codex/auth.json", + ) + argv_seen = [call.args[1] for call in ex.call_args_list] + self.assertIn(["mkdir", "-p", "/home/node/.codex"], argv_seen) + self.assertIn( + ["chown", "node:node", "/home/node/.codex/auth.json"], + argv_seen, + ) + self.assertIn(["chmod", "600", "/home/node/.codex/auth.json"], argv_seen) + + class TestProvisionSkills(unittest.TestCase): def _patch_host_skill_dir(self, returns: dict[str, str]): return patch(