fix(codex): provision dummy user auth state

This commit is contained in:
2026-05-29 03:46:15 -04:00
committed by didericis
parent 62dd7b2aa5
commit a6332b9535
15 changed files with 406 additions and 31 deletions
+9 -7
View File
@@ -369,13 +369,15 @@ egress:
``` ```
Run `codex login --device-auth` on the host before launch. The Run `codex login --device-auth` on the host before launch. The
launcher reads only `tokens.access_token` from the host's launcher reads `tokens.access_token` from the host's
`~/.codex/auth.json`, verifies it is fresh ChatGPT auth, and passes it `~/.codex/auth.json`, verifies it is fresh user/device auth, and passes
to the sidecar's `EGRESS_TOKEN_N` env slot. The agent container does it to the sidecar's `EGRESS_TOKEN_N` env slot. The agent container gets
not receive `auth.json`, refresh tokens, access-token env vars, or a dummy `~/.codex/auth.json` that preserves the host auth-mode shape
`OPENAI_API_KEY`. The effective egress table automatically adds or but replaces credential values with placeholders, so Codex chooses the
upgrades `api.openai.com` and `chatgpt.com` to authenticated routes user/device auth path without receiving real access tokens, refresh
when `forward_host_credentials` is true. 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 The built-in Codex template uses `Dockerfile.codex`; set
`agent_provider.dockerfile` to build the agent from a custom Dockerfile `agent_provider.dockerfile` to build the agent from a custom Dockerfile
+6
View File
@@ -286,6 +286,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
intercepted without per-tool reconfiguration.""" intercepted without per-tool reconfiguration."""
self.provision_ca(plan, target) self.provision_ca(plan, target)
prompt_path = self.provision_prompt(plan, target) prompt_path = self.provision_prompt(plan, target)
self.provision_provider_auth(plan, target)
self.provision_skills(plan, target) self.provision_skills(plan, target)
self.provision_git(plan, target) self.provision_git(plan, target)
self.provision_supervise(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 backend overrides to docker-cp the cert in and run
`update-ca-certificates`.""" `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 @abstractmethod
def provision_prompt(self, plan: PlanT, target: str) -> str | None: def provision_prompt(self, plan: PlanT, target: str) -> str | None:
"""Copy the prompt file into the running bottle. Returns the """Copy the prompt file into the running bottle. Returns the
+4
View File
@@ -29,6 +29,7 @@ from .bottle_plan import DockerBottlePlan
from .provision import ca as _ca from .provision import ca as _ca
from .provision import git as _git from .provision import git as _git
from .provision import prompt as _prompt from .provision import prompt as _prompt
from .provision import provider_auth as _provider_auth
from .provision import skills as _skills from .provision import skills as _skills
from .provision import supervise as _supervise_prov 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: def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
return _prompt.provision_prompt(plan, target) 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: def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
_skills.provision_skills(plan, target) _skills.provision_skills(plan, target)
+1
View File
@@ -55,6 +55,7 @@ class DockerBottlePlan(BottlePlan):
agent_command: str = "claude" agent_command: str = "claude"
agent_prompt_mode: PromptMode = "append_file" agent_prompt_mode: PromptMode = "append_file"
agent_provider_template: str = "claude" agent_provider_template: str = "claude"
codex_auth_file: Path | None = None
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr — compact form """Render the y/N preflight summary to stderr — compact form
+5
View File
@@ -15,6 +15,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ...agent_provider import runtime_for from ...agent_provider import runtime_for
from ...codex_auth import write_codex_dummy_auth_file
from ...egress import Egress from ...egress import Egress
from ...env import ResolvedEnv, resolve_env from ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate from ...git_gate import GitGate
@@ -155,6 +156,7 @@ def resolve_plan(
agent_dir.mkdir(parents=True, exist_ok=True) agent_dir.mkdir(parents=True, exist_ok=True)
env_file = agent_dir / "agent.env" env_file = agent_dir / "agent.env"
prompt_file = agent_dir / "prompt.txt" prompt_file = agent_dir / "prompt.txt"
codex_auth_file = agent_dir / "codex-auth.json"
prompt_file.write_text("") prompt_file.write_text("")
prompt_file.chmod(0o600) prompt_file.chmod(0o600)
@@ -219,6 +221,8 @@ def resolve_plan(
# error reporting) that egress can't gate by auth. # error reporting) that egress can't gate by auth.
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "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) _write_env_file(resolved, env_file)
prompt_file.write_text(agent.prompt) prompt_file.write_text(agent.prompt)
@@ -245,6 +249,7 @@ def resolve_plan(
agent_command=provider_runtime.command, agent_command=provider_runtime.command,
agent_prompt_mode=provider_runtime.prompt_mode, agent_prompt_mode=provider_runtime.prompt_mode,
agent_provider_template=provider.template, agent_provider_template=provider.template,
codex_auth_file=codex_auth_file if provider.forward_host_credentials else None,
) )
@@ -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,
)
@@ -19,6 +19,7 @@ from .bottle_plan import SmolmachinesBottlePlan
from .provision import ca as _ca from .provision import ca as _ca
from .provision import git as _git from .provision import git as _git
from .provision import prompt as _prompt from .provision import prompt as _prompt
from .provision import provider_auth as _provider_auth
from .provision import skills as _skills from .provision import skills as _skills
from .provision import supervise as _supervise from .provision import supervise as _supervise
@@ -61,6 +62,11 @@ class SmolmachinesBottleBackend(
) -> str | None: ) -> str | None:
return _prompt.provision_prompt(plan, target) 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( def provision_skills(
self, plan: SmolmachinesBottlePlan, target: str self, plan: SmolmachinesBottlePlan, target: str
) -> None: ) -> None:
@@ -97,6 +97,7 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_prompt_mode: PromptMode = "append_file" agent_prompt_mode: PromptMode = "append_file"
agent_provider_template: str = "claude" agent_provider_template: str = "claude"
agent_dockerfile_path: str = "" agent_dockerfile_path: str = ""
codex_auth_file: Path | None = None
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Compact y/N preflight. Same shape as the Docker """Compact y/N preflight. Same shape as the Docker
@@ -16,6 +16,7 @@ from pathlib import Path
from ...agent_provider import runtime_for from ...agent_provider import runtime_for
from ...backend import BottleSpec from ...backend import BottleSpec
from ...codex_auth import write_codex_dummy_auth_file
from ...backend.docker.bottle_state import ( from ...backend.docker.bottle_state import (
BottleMetadata, BottleMetadata,
agent_state_dir, agent_state_dir,
@@ -144,9 +145,12 @@ def resolve_plan(
agent_dir = agent_state_dir(slug) agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True) agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt" prompt_file = agent_dir / "prompt.txt"
codex_auth_file = agent_dir / "codex-auth.json"
agent = manifest.agents[spec.agent_name] agent = manifest.agents[spec.agent_name]
prompt_file.write_text(agent.prompt or "") prompt_file.write_text(agent.prompt or "")
prompt_file.chmod(0o600) 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}" machine_name = f"bot-bottle-{slug}"
# Stash the agent image ref — `launch.launch` runs the # Stash the agent image ref — `launch.launch` runs the
@@ -182,6 +186,7 @@ def resolve_plan(
agent_prompt_mode=provider_runtime.prompt_mode, agent_prompt_mode=provider_runtime.prompt_mode,
agent_provider_template=provider.template, agent_provider_template=provider.template,
agent_dockerfile_path=agent_dockerfile_path, agent_dockerfile_path=agent_dockerfile_path,
codex_auth_file=codex_auth_file if provider.forward_host_credentials else None,
) )
@@ -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])
+84 -9
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import base64 import base64
import json import json
import os import os
from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -37,16 +38,12 @@ def codex_host_access_token(
"Run `codex login --device-auth` on the host or disable " "Run `codex login --device-auth` on the host or disable "
"agent_provider.forward_host_credentials." "agent_provider.forward_host_credentials."
) )
try: raw = _read_auth_object(path)
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")
if raw.get("auth_mode") != "chatgpt": auth_mode = raw.get("auth_mode")
if not isinstance(auth_mode, str) or auth_mode == "api_key":
die( 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." "auth. Run `codex login --device-auth` on the host."
) )
@@ -72,6 +69,79 @@ def codex_host_access_token(
return access 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: def _jwt_exp(token: str) -> datetime | None:
parts = token.split(".") parts = token.split(".")
if len(parts) < 2: if len(parts) < 2:
@@ -93,4 +163,9 @@ def _b64url_decode(value: str) -> str:
return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8") 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",
]
+24 -14
View File
@@ -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.com` routes therefore forward Codex requests without the
ChatGPT bearer token. ChatGPT bearer token.
Copying `~/.codex/auth.json` into the agent would solve auth but would Copying the host `~/.codex/auth.json` into the agent would solve auth
also put access and refresh material inside the agent sandbox. That cuts mode detection but would also put access and refresh material inside the
against bot-bottle's credential minimization model: provider credentials agent sandbox. That cuts against bot-bottle's credential minimization
should live in the sidecar boundary when possible, not in the agent. model: provider credentials should live in the sidecar boundary when
possible, not in the agent.
## Goals / Success Criteria ## 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 - Host credential forwarding happens only when the bottle declares
`agent_provider.forward_host_credentials: true`. `agent_provider.forward_host_credentials: true`.
- The agent container does not receive `OPENAI_API_KEY`, - The agent container does not receive `OPENAI_API_KEY`,
`CODEX_ACCESS_TOKEN`, `tokens.access_token`, `tokens.refresh_token`, `CODEX_ACCESS_TOKEN`, or real `tokens.access_token` /
or `auth.json`. `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 - Egress route files remain non-secret: they contain only host/path/auth
slot metadata, never token values. 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. launch with a clear operator-facing message.
- Existing Claude OAuth placeholder behavior remains unchanged. - 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 - Refreshing Codex tokens in the sidecar. The first cut reads the host's
current access token at launch; operators can restart after host Codex current access token at launch; operators can restart after host Codex
refreshes auth. 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 - Allowing arbitrary host credential forwarding. This PRD covers Codex
ChatGPT/device-login credentials only. ChatGPT/device-login credentials only.
- Hot-applying new authenticated Codex routes to an existing running - 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`. - Support the flag for `agent_provider.template: codex`.
- Read host Codex auth from `$CODEX_HOME/auth.json` when `CODEX_HOME` is - Read host Codex auth from `$CODEX_HOME/auth.json` when `CODEX_HOME` is
set, otherwise from `~/.codex/auth.json`. set, otherwise from `~/.codex/auth.json`.
- Extract only `tokens.access_token`. - Extract only `tokens.access_token` for egress injection.
- Validate that `auth_mode` is `chatgpt` and the access token is present, - Generate a dummy agent-side `auth.json` from the host auth file's
JWT-shaped, and not expired. 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 - Add or upgrade `api.openai.com` and `chatgpt.com` egress routes to
inject that access token via a shared `EGRESS_TOKEN_N` sidecar env inject that access token via a shared `EGRESS_TOKEN_N` sidecar env
slot. 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 1. Resolve the host Codex home directory from `$CODEX_HOME`, falling
back to `~/.codex`. back to `~/.codex`.
2. Parse `auth.json`. 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`. 4. Require a non-empty `tokens.access_token`.
5. Parse the JWT payload enough to require an `exp` claim in the future. 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. 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 flowchart LR
H["Host ~/.codex/auth.json"] --> L["bot-bottle launch"] H["Host ~/.codex/auth.json"] --> L["bot-bottle launch"]
L -->|access token only| S["egress sidecar env"] L -->|access token only| S["egress sidecar env"]
L -->|dummy auth.json only| A
A["Codex agent"] -->|HTTPS via proxy, auth stripped| E["egress"] A["Codex agent"] -->|HTTPS via proxy, auth stripped| E["egress"]
E -->|Bearer injected from env| C["api.openai.com / chatgpt.com"] 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 4. **Effective egress route.** Add/upgrade the Codex API routes when the
flag is enabled, and add tests for bare route upgrade, flag is enabled, and add tests for bare route upgrade,
missing-route insertion, and authenticated-route conflict. 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. 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 ## Open questions
+45 -1
View File
@@ -9,7 +9,11 @@ import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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 from bot_bottle.log import Die
@@ -59,6 +63,15 @@ class TestCodexHostAccessToken(unittest.TestCase):
with self.assertRaises(Die): with self.assertRaises(Die):
codex_host_access_token({"CODEX_HOME": str(self.home)}) 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): def test_expired_token_dies(self):
self._write({ self._write({
"auth_mode": "chatgpt", "auth_mode": "chatgpt",
@@ -78,6 +91,37 @@ class TestCodexHostAccessToken(unittest.TestCase):
with self.assertRaises(Die): with self.assertRaises(Die):
codex_host_access_token({"CODEX_HOME": str(self.home)}) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -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()
+39
View File
@@ -21,6 +21,7 @@ from bot_bottle.backend.smolmachines.provision import (
ca as _ca, ca as _ca,
git as _git, git as _git,
prompt as _prompt, prompt as _prompt,
provider_auth as _provider_auth,
skills as _skills, skills as _skills,
supervise as _supervise, supervise as _supervise,
) )
@@ -55,6 +56,7 @@ def _plan(
bundle_ip: str = "192.168.50.2", bundle_ip: str = "192.168.50.2",
agent_git_gate_host: str = "127.0.0.1:55555", agent_git_gate_host: str = "127.0.0.1:55555",
agent_supervise_url: str = "http://127.0.0.1:55556/", agent_supervise_url: str = "http://127.0.0.1:55556/",
codex_auth_file: Path | None = None,
) -> SmolmachinesBottlePlan: ) -> SmolmachinesBottlePlan:
bottle_json: dict = {} bottle_json: dict = {}
git_json: dict = {} git_json: dict = {}
@@ -129,6 +131,7 @@ def _plan(
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
agent_git_gate_host=agent_git_gate_host, agent_git_gate_host=agent_git_gate_host,
agent_supervise_url=agent_supervise_url, agent_supervise_url=agent_supervise_url,
codex_auth_file=codex_auth_file,
) )
@@ -189,6 +192,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): class TestProvisionSkills(unittest.TestCase):
def _patch_host_skill_dir(self, returns: dict[str, str]): def _patch_host_skill_dir(self, returns: dict[str, str]):
return patch( return patch(