fix(codex): provision dummy user auth state
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user