Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 58 additions & 16 deletions docs/auth.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
# Container authentication — giving tasks a `claude` token
# Container authentication — giving a task's agent CLI its credentials

Every task runs `claude` inside its container. The agent authenticates from a
**`CLAUDE_CODE_OAUTH_TOKEN`** environment variable, which the runner injects from the **repo's
`env_file`** at spawn (ADR 0007 / ADR 0012). You provide that token once per repo; it is long-lived
and non-rotating, so it survives concurrent tasks and respawns (no ~8h re-login cliff).
Every task runs an agent CLI inside its container — **`claude` by default, or `codex`** when the
repo's `agent_cli` selects it. Either way the agent authenticates from credentials the runner injects
from the **repo's `env_file`** at spawn (ADR 0007 / ADR 0012); which variable(s) you set depends on
the CLI. This page covers **claude** first (the default), then **codex** — tasks whose `agent_cli`
is `codex`.

## Claude (the default)

Claude authenticates from a **`CLAUDE_CODE_OAUTH_TOKEN`** environment variable, which the runner
injects from the repo's `env_file`. You provide that token once per repo; it is long-lived and
non-rotating, so it survives concurrent tasks and respawns (no ~8h re-login cliff).

Normally you don't set this up by hand: **`panopticon quickstart` registers the repo and drops you
into a `setup-repo` task** that mints the token and writes it into the env-file for you. This page is
the deep-dive and the manual path — set it up by hand (mint with the `claude` CLI, drop the token
into a `setup-repo` task** that mints the token and writes it into the env-file for you. This section
is the deep-dive and the manual path — set it up by hand (mint with the `claude` CLI, drop the token
into the env-file — below), or run the **`setup-repo` workflow** on its own (see *The `setup-repo`
workflow* below). There is no `login` command.

## One-time setup per account
### One-time setup per account

1. **Mint a long-lived token** on a machine where you can complete the browser OAuth (it needs a
Claude subscription or Console login):
Expand Down Expand Up @@ -47,7 +54,7 @@ workflow* below). There is no `login` command.

That's it — new task containers for that repo now authenticate from the token.

## The `setup-repo` workflow
### The `setup-repo` workflow

`panopticon quickstart` runs this workflow for you. To do it manually, start a **`setup-repo`** task
from the repos modal — press `g` on the dashboard, highlight the repo, and press `s`.
Expand All @@ -59,19 +66,54 @@ the previous line is **commented out** (kept as a record, not deleted) and any p
it can't capture the token (or the repo has no `env_file`), it falls back to printing the copy-it-in
instructions above.

## Notes
## Codex (tasks whose `agent_cli` is `codex`)

A repo whose `agent_cli` is `codex` runs the `codex` CLI in its task containers instead of `claude`,
and codex authenticates differently: it reads credentials from `$CODEX_HOME/auth.json`, not from an
env var directly, and otherwise reaches for an OS keyring the container doesn't have. The container
adapter bridges this — it pins codex to the **file** credential store and, on the container's first
launch, materializes `auth.json` from whatever key you put in the repo's env-file.

So setup is the same shape as claude's: **add one line to the repo's `env_file`** (see *Add it to the
repo's env-file* above for how to create one and point the repo at it). Choose **one**:

- **API key** — `OPENAI_API_KEY=sk-…` (or `CODEX_API_KEY=sk-…`; both spellings are accepted). The
standard API-billed key from platform.openai.com. On first launch the adapter writes
`$CODEX_HOME/auth.json` as `{"auth_mode": "apikey", "OPENAI_API_KEY": "…"}` (mode `0600`).
- **ChatGPT workspace token** — `CODEX_ACCESS_TOKEN=…`, a ChatGPT Business/Enterprise workspace
access token (minted at chatgpt.com/admin → access tokens), the analog of `claude setup-token`.
Codex reads it straight from the env; no file is written.

That's it — new codex task containers for that repo now authenticate from the env-file. There is no
`setup-repo` equivalent for codex yet, so add the line by hand.

Notes specific to codex:

- **Idempotent, never clobbered.** If `auth.json` already exists on the per-task config volume (a
container already logged in, e.g. carried across respawn), the adapter leaves it untouched — and a
container that has only a persisted `auth.json` still counts as authenticated.
- **No validation up front.** We only check that a credential is *present*; an invalid or expired key
surfaces at codex's first call, not at launch.
- **Rotating an API key.** Because `auth.json` lives on the per-task config volume and is written
once (never clobbered), changing `OPENAI_API_KEY` in the env-file and respawning **won't** re-auth
an existing task — its `auth.json` is already there. New tasks pick up the new key; to rotate a
live one, clear its `auth.json` from the per-task volume before respawn. (`CODEX_ACCESS_TOKEN`,
read from the env, has no such caching — respawn picks up a change.)

## Notes (both CLIs)

- **The env-file lives on the host that spawns the container.** Because `env_file` is stored as a
bare name resolved against each runner's own `~/.config/panopticon/secrets/`, the same repo record
works across hosts: with a single host (M1) that's the machine you minted on; with remote runners
(M5), place a same-named env-file under each runner host's secrets dir.
- **`ANTHROPIC_API_KEY` overrides `CLAUDE_CODE_OAUTH_TOKEN`.** If a repo needs to burst past the
subscription rate limit, put an `ANTHROPIC_API_KEY` in the same env-file — but don't set both
- **`ANTHROPIC_API_KEY` overrides `CLAUDE_CODE_OAUTH_TOKEN` (claude).** If a repo needs to burst past
the subscription rate limit, put an `ANTHROPIC_API_KEY` in the same env-file — but don't set both
unintentionally, since the API key wins.
- **Already-running tasks** keep their old token until they respawn. After editing the env-file,
respawn a live task from the dashboard (`R`) to pick up the new value.
- **Rotating/revoking.** To replace a token, mint a new one and overwrite the env-file line (or
re-run the `setup-repo` workflow, which comments out the old line and appends the new one).
- **Already-running tasks** keep their old credentials until they respawn. After editing the
env-file, respawn a live task from the dashboard (`R`) to pick up the new value (for codex, mind
the `auth.json` caching noted above).
- **Rotating/revoking (claude).** To replace a token, mint a new one and overwrite the env-file line
(or re-run the `setup-repo` workflow, which comments out the old line and appends the new one).
Per-token revocation isn't available upstream (account-level "revoke all" can take time to
propagate), so treat a leak as "mint a replacement + monitor usage in the Console," and keep the
env-file tightly held.
9 changes: 7 additions & 2 deletions src/panopticon/container/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
The launcher is **CLI-agnostic** (ADR 0014): it resolves an :class:`~panopticon.container.cli.AgentCLI`
adapter from the CLI name the runner passes (``PANOPTICON_AGENT_CLI``, defaulting to ``claude``) and
drives the bootstrap-then-launch sequence against it, holding no ``claude`` literal. Auth is the
adapter's env-var check; the launcher wires no credentials of its own.
adapter's env-var check plus its :meth:`~panopticon.container.cli.AgentCLI.write_credentials` step
(claude reads its token from the env; codex materializes ``auth.json``) — the launcher itself knows
no CLI-specific credential shape.

The container's entrypoint (`python -m panopticon.container`) holds the liveness connection;
this runs alongside it in the tmux pane, so `tmux attach` reaches the live agent.
Expand Down Expand Up @@ -62,7 +64,7 @@ def main(
config_dir = (home or Path.home()) / cli.config_dirname
task_id = env["PANOPTICON_TASK_ID"]
runner_id = env.get("PANOPTICON_RUNNER_ID")
detail = cli.auth_missing_detail(env)
detail = cli.auth_missing_detail(env, config_dir)
if detail is not None:
if runner_id:
client.report_lifecycle(task_id, runner_id, phase="failed", detail=detail)
Expand All @@ -75,6 +77,9 @@ def main(
config_dir, client.workflow_overview(task_id)
) # → the agent's context (the map)
cli.trust_workspace(config_dir, Path.cwd()) # pre-accept the trust dialog (no operator to)
cli.write_credentials(
config_dir, env
) # materialize on-disk creds (codex auth.json; claude no-op)
(launch or cli.launch)(config_dir) # the agent runs until it exits...
on_exit() # ...then stop the container (task → down → respawn)

Expand Down
17 changes: 15 additions & 2 deletions src/panopticon/container/cli/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,21 @@ def trust_workspace(self, config_dir: Path, cwd: Path) -> Path:
"""Pre-accept the CLI's first-run/trust dialogs for ``cwd`` (no operator in the container)."""

@abstractmethod
def auth_missing_detail(self, env: Mapping[str, str]) -> str | None:
"""The failure detail if the CLI's auth env var is absent, else ``None`` (auth is present)."""
def auth_missing_detail(self, env: Mapping[str, str], config_dir: Path) -> str | None:
"""The failure detail if the CLI can't authenticate, else ``None``.

Auth is present when the CLI's env var is set **or** a persisted credential already sits on
the per-task config volume (``config_dir``) — so a container carried across respawn isn't
wrongly failed. Presence check only; validity surfaces at the CLI's first call.
"""

@abstractmethod
def write_credentials(self, config_dir: Path, env: Mapping[str, str]) -> Path | None:
"""Materialize any on-disk credentials the CLI needs from the env (idempotent).

Returns the written path, or ``None`` when the CLI reads its credentials straight from the
env (claude) or a credential file is already present. Never clobbers an existing one.
"""

@abstractmethod
def resolve_model(self, tier: str) -> str:
Expand Down
9 changes: 7 additions & 2 deletions src/panopticon/container/cli/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,18 +113,23 @@ def trust_workspace(self, config_dir: Path, cwd: Path) -> Path:
projects.setdefault(str(cwd), {})["hasTrustDialogAccepted"] = True
return config

def auth_missing_detail(self, env: Mapping[str, str]) -> str | None:
def auth_missing_detail(self, env: Mapping[str, str], config_dir: Path) -> str | None:
"""The failure detail when neither claude auth env var is set, else ``None``.

Auth is the ``CLAUDE_CODE_OAUTH_TOKEN`` env var the runner injects from the repo's
``env_file`` (an ``ANTHROPIC_API_KEY`` is also sufficient); the launcher wires no credentials.
``env_file`` (an ``ANTHROPIC_API_KEY`` is also sufficient); claude reads it straight from the
env, so there's no persisted credential file to fall back on — ``config_dir`` is unused.
"""
if env.get("CLAUDE_CODE_OAUTH_TOKEN") or env.get("ANTHROPIC_API_KEY"):
return None
return (
"No auth token — set CLAUDE_CODE_OAUTH_TOKEN in the repo's env_file (see docs/auth.md)"
)

def write_credentials(self, config_dir: Path, env: Mapping[str, str]) -> Path | None:
"""No-op: claude authenticates from the env var itself, with no on-disk credential to write."""
return None

def resolve_model(self, tier: str) -> str:
"""Map the control plane's abstract model tier to claude's concrete model id (ADR 0014 §3a).

Expand Down
66 changes: 59 additions & 7 deletions src/panopticon/container/cli/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
``/workspace/AGENTS.md``), which layers additively on top of the repo's own instructions;
- **trust / unattended posture** → ``config.toml`` (project ``trust_level`` + ``approval_policy`` /
``sandbox_mode``) so a headless container isn't blocked, on first run *and* on resume;
- **auth** → ``OPENAI_API_KEY``;
- **auth** → an API key (``CODEX_API_KEY`` / ``OPENAI_API_KEY``) materialized into
``$CODEX_HOME/auth.json`` (a bare env var does *not* log codex in), or a ChatGPT workspace
access token (``CODEX_ACCESS_TOKEN``) read straight from the env — see :meth:`write_credentials`;
- **launch / resume** → ``codex`` first-run vs ``codex resume --last`` (the ``claude --continue``
analogue), probing ``$CODEX_HOME/sessions`` for a prior transcript.

Expand Down Expand Up @@ -72,6 +74,12 @@ class CodexAgentCLI(AgentCLI):
WORKFLOW_OVERVIEW_FILE: ClassVar[str] = "AGENTS.md"
#: Session transcripts live here under the config dir; their presence means "resume" (§ launch).
SESSIONS_DIRNAME: ClassVar[str] = "sessions"
#: codex's credentials file under the config home — what ``codex login --with-api-key`` writes.
AUTH_FILE: ClassVar[str] = "auth.json"
#: Env-var spellings carrying an OpenAI API key we materialize into :attr:`AUTH_FILE`.
API_KEY_VARS: ClassVar[tuple[str, ...]] = ("CODEX_API_KEY", "OPENAI_API_KEY")
#: The ChatGPT workspace access token (the ``claude setup-token`` analog); read from the env, no file.
ACCESS_TOKEN_VAR: ClassVar[str] = "CODEX_ACCESS_TOKEN"

def render_skills(self, client: _Client, task_id: str, home: Path) -> list[Path]:
"""Render the workflow's skills to ``~/.codex/prompts/`` (codex's custom-prompt surface)."""
Expand Down Expand Up @@ -167,15 +175,59 @@ def trust_workspace(self, config_dir: Path, cwd: Path) -> Path:
projects.setdefault(str(cwd), {})["trust_level"] = "trusted"
return config

def auth_missing_detail(self, env: Mapping[str, str]) -> str | None:
"""The failure detail when codex's auth env var is absent, else ``None``.
def auth_missing_detail(self, env: Mapping[str, str], config_dir: Path) -> str | None:
"""The failure detail when codex has no way to authenticate, else ``None``.

Codex is satisfied by any of the auth vars the runner injects from the repo's ``env_file``
(ADR 0007 / 0012 generalize per CLI) — an API key (``CODEX_API_KEY`` / ``OPENAI_API_KEY``,
which :meth:`write_credentials` materializes into ``auth.json``) or a ChatGPT workspace access
token (``CODEX_ACCESS_TOKEN``, read straight from the env) — **or** a pre-existing
``auth.json`` on the per-task config volume (a container already logged in, e.g. carried
across respawn — which a bare env check would wrongly fail). Presence checks only: we don't
validate the key shape (OpenAI's format isn't ours to pin); an invalid credential surfaces at
codex's first call.
"""
if any(env.get(var) for var in (*self.API_KEY_VARS, self.ACCESS_TOKEN_VAR)):
return None
if (config_dir / self.AUTH_FILE).exists():
return None
return (
"No codex auth — set OPENAI_API_KEY (or CODEX_API_KEY / CODEX_ACCESS_TOKEN) in the "
"repo's env_file (see docs/auth.md)"
)

def write_credentials(self, config_dir: Path, env: Mapping[str, str]) -> Path | None:
"""Materialize codex's ``auth.json`` from an API key in the env, and pin the file cred store.

A bare ``OPENAI_API_KEY`` in the container env does **not** log codex in — codex
authenticates from ``$CODEX_HOME/auth.json`` and may otherwise reach for an OS keyring the
container lacks. So we:

Auth is ``OPENAI_API_KEY``, injected by the runner from the repo's ``env_file`` (ADR 0007 /
0012 generalize per CLI); the launcher wires no credentials.
- set ``cli_auth_credentials_store = "file"`` (top-level ``config.toml``) so codex reads
credentials from the file, never a keyring — done unconditionally, so it also governs a
pre-existing ``auth.json`` carried across respawn;
- when ``auth.json`` is absent, render it from ``CODEX_API_KEY`` or ``OPENAI_API_KEY`` in the
exact shape ``codex login --with-api-key`` writes — ``{"auth_mode": "apikey",
"OPENAI_API_KEY": <key>}`` — at mode ``0600``.

**Idempotent: an existing ``auth.json`` is never clobbered**, so a container already logged in
keeps its credentials. Returns the ``auth.json`` path when written, else ``None`` (no API key,
or one already present). A workspace access token (``CODEX_ACCESS_TOKEN``) needs no file —
codex reads it from the env — so it doesn't trigger a write here (the auth gate accepts it).
"""
if env.get("OPENAI_API_KEY"):
config = config_dir / self.CONFIG_FILE
with update_toml_config(config) as data:
data["cli_auth_credentials_store"] = "file"
auth = config_dir / self.AUTH_FILE
if auth.exists():
return None
return "No auth token — set OPENAI_API_KEY in the repo's env_file (see docs/auth.md)"
key = next((env[var] for var in self.API_KEY_VARS if env.get(var)), None)
if not key:
return None
config_dir.mkdir(parents=True, exist_ok=True)
auth.write_text(json.dumps({"auth_mode": "apikey", "OPENAI_API_KEY": key}))
auth.chmod(0o600)
return auth

def resolve_model(self, tier: str) -> str:
"""Map the control plane's abstract model tier to codex's concrete model id (ADR 0014 §3a).
Expand Down
7 changes: 6 additions & 1 deletion tests/container/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ class _FakeCLI(ClaudeAgentCLI):
name = "fake"
config_dirname = ".fake"

def auth_missing_detail(self, env: object) -> str | None:
def auth_missing_detail(self, env: object, config_dir: object) -> str | None:
calls.append("auth")
return None

Expand Down Expand Up @@ -118,6 +118,10 @@ def trust_workspace(self, config_dir: Path, cwd: Path) -> Path:
calls.append("trust")
return config_dir

def write_credentials(self, config_dir: Path, env: object) -> Path | None:
calls.append("credentials")
return None

def launch(self, config_dir: Path) -> None:
calls.append(f"launch:{config_dir}")

Expand All @@ -136,6 +140,7 @@ def launch(self, config_dir: Path) -> None:
f"mcp:{tmp_path / '.fake'}",
"overview",
"trust",
"credentials",
f"launch:{tmp_path / '.fake'}",
"on_exit",
]
Expand Down
15 changes: 10 additions & 5 deletions tests/container/test_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,17 @@ def test_trust_workspace_merges_and_is_idempotent(tmp_path: Path) -> None:
# -- auth env check -----------------------------------------------------------------------------


def test_auth_missing_detail_flags_the_absent_token() -> None:
def test_auth_missing_detail_flags_the_absent_token(tmp_path: Path) -> None:
cli = ClaudeAgentCLI()
assert cli.auth_missing_detail({}) is not None
assert "CLAUDE_CODE_OAUTH_TOKEN" in (cli.auth_missing_detail({}) or "")
assert cli.auth_missing_detail({"CLAUDE_CODE_OAUTH_TOKEN": "sk"}) is None
assert cli.auth_missing_detail({"ANTHROPIC_API_KEY": "sk"}) is None # either is sufficient
assert cli.auth_missing_detail({}, tmp_path) is not None
assert "CLAUDE_CODE_OAUTH_TOKEN" in (cli.auth_missing_detail({}, tmp_path) or "")
assert cli.auth_missing_detail({"CLAUDE_CODE_OAUTH_TOKEN": "sk"}, tmp_path) is None
assert cli.auth_missing_detail({"ANTHROPIC_API_KEY": "sk"}, tmp_path) is None # either suffices


def test_write_credentials_is_a_no_op_for_claude(tmp_path: Path) -> None:
# claude reads its token from the env; there's no on-disk credential to materialize.
assert ClaudeAgentCLI().write_credentials(tmp_path, {"CLAUDE_CODE_OAUTH_TOKEN": "sk"}) is None


# -- hook payload seam (background-task gating) --------------------------------------------------
Expand Down
5 changes: 4 additions & 1 deletion tests/container/test_cli_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ def write_workflow_overview(self, config_dir: Path, overview: str) -> Path | Non
def trust_workspace(self, config_dir: Path, cwd: Path) -> Path:
return config_dir

def auth_missing_detail(self, env: object) -> str | None:
def auth_missing_detail(self, env: object, config_dir: object) -> str | None:
return None

def write_credentials(self, config_dir: Path, env: object) -> Path | None:
return None

def resolve_model(self, tier: str) -> str:
Expand Down
Loading
Loading