From 711a0a573acd072945bb8e6577a03b80e778e3a4 Mon Sep 17 00:00:00 2001 From: panopticon-agent Date: Wed, 15 Jul 2026 22:47:38 +0000 Subject: [PATCH] refactor(harnesses): extract the claude launcher behind a Harness interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up src/panopticon/harnesses/ — a pluggable adapter describing one agent CLI's mechanics (config dirname, auth check, bootstrap file-writes, launch argv) — and re-express the current claude launcher as ClaudeHarness. container/agent.py becomes a thin driver that looks the harness up in a registry and dispatches to it, staying the only place a CLI is executed (the determinism invariant). Behaviour-preserving: the claude golden argv/rendering tests carry over unchanged in substance (only import paths move). The launcher selects the harness from PANOPTICON_HARNESS, defaulting to claude, so an existing claude task is launched exactly as before. This is chunk 1/4 of the M3 harnesses work (PR #339). Chunks 2-4 stack on top: control-plane recording (Task.harness / Repo.credential_dir), runner image/spawn wiring, and the codex harness. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 18 +- src/panopticon/container/agent.py | 226 +++----------- src/panopticon/container/hooks.py | 68 ---- src/panopticon/container/skills.py | 68 ---- src/panopticon/harnesses/__init__.py | 47 +++ src/panopticon/harnesses/base.py | 108 +++++++ src/panopticon/harnesses/claude.py | 259 ++++++++++++++++ .../{container => harnesses}/config.py | 6 +- tests/container/test_agent.py | 290 ++++++------------ tests/container/test_hooks.py | 2 +- tests/harnesses/test_claude.py | 189 ++++++++++++ .../test_claude_skills.py} | 2 +- tests/{container => harnesses}/test_config.py | 2 +- tests/harnesses/test_registry.py | 40 +++ 14 files changed, 803 insertions(+), 522 deletions(-) delete mode 100644 src/panopticon/container/hooks.py delete mode 100644 src/panopticon/container/skills.py create mode 100644 src/panopticon/harnesses/__init__.py create mode 100644 src/panopticon/harnesses/base.py create mode 100644 src/panopticon/harnesses/claude.py rename src/panopticon/{container => harnesses}/config.py (85%) create mode 100644 tests/harnesses/test_claude.py rename tests/{container/test_skills.py => harnesses/test_claude_skills.py} (95%) rename tests/{container => harnesses}/test_config.py (95%) create mode 100644 tests/harnesses/test_registry.py diff --git a/AGENTS.md b/AGENTS.md index 339639fd..7c4fc234 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,13 @@ src/panopticon/ # its shell_script in a host tmux session (here: `claude setup-token`)) + # discovery.py = scan the package + an optional path for Workflow subclasses # (the registry build_app runs on; drop a module in → registered, ADR 0004) + harnesses/ # agent-CLI harnesses (M3): the Harness interface + the registry (a literal + # mapping — claude only for now; path discovery waits for a real third-party + # need) + claude.py (the default, extracted from the Slice-6 launcher: argv, + # .claude/commands rendering, turn-flip settings.json, MCP config, trust + # seeds) + config.py (the JSON-config write helper). LLM-free: harnesses + # DESCRIBE and RENDER a CLI; only the container's launcher EXECUTES one. The + # launcher selects by name (PANOPTICON_HARNESS, default claude) taskservice/ # control plane: TaskService, FastAPI REST API, the SQLAlchemy store # adapter (in-memory or on-disk SQLite), filesystem artifact store, MCP # server (mcp.py: operations=tools, artifacts=resources; FastMCP) mounted at /mcp @@ -51,8 +58,10 @@ src/panopticon/ # spawns one task container/ # entrypoint (`python -m panopticon.container` = connect/register/slug/ # heartbeat liveness) + agent.py (`-m panopticon.container.agent` = the tmux - # pane's launcher: render skills + operations, point claude at the /mcp server, - # put the workflow overview in its system prompt → exec `claude`) — the ONLY LLM pkg + # pane's launcher: fetch the workflow surface, dispatch to the task's harness + # (bootstrap = pure file writes: skills, operations, MCP, workflow overview), + # then run its argv) + hook.py (the turn-flip callback the harness's hooks + # invoke) — the ONLY LLM pkg (the launch) docker/Dockerfile # base task-container image (ADR 0005 base layer): python + git + bash + # the panopticon package + the `claude` CLI the agent execs; runs as the # unprivileged `panopticon` user. docker/entrypoint.sh = remap that user to the @@ -149,6 +158,11 @@ on every PR (the same commands the Makefile wraps). ## Tests worth knowing +- `tests/harnesses/` — the **agent-CLI harness suite** (M3): the registry (names, the claude + default, unknown-name rejection), `test_claude.py` (the Slice-6 argv/rendering expectations + carried over verbatim — the seam extraction must not change what claude is launched with), + `test_claude_skills.py` (skill → `.claude/commands` rendering), and `test_config.py` (the + JSON-config write helper). Extend it when you touch a harness or add one. - `tests/test_workflow.py` — the **golden harness**: every legal/illegal transition, turn derivation, responsibility gating, and workflow validation. Extend it when you touch the state machine. diff --git a/src/panopticon/container/agent.py b/src/panopticon/container/agent.py index ce10c642..6fe962bb 100644 --- a/src/panopticon/container/agent.py +++ b/src/panopticon/container/agent.py @@ -1,12 +1,16 @@ """The in-container **agent launcher** — what the runner's tmux pane runs. It prepares the agent CLI's surface from the active workflow (skills + turn-flip hooks), then -`exec`s the agent. This is the only LLM-bearing path (the determinism invariant): the -**bootstrap** is deterministic and unit-tested with fakes; the **launch** (real `claude`) is +runs the agent. Which CLI is the task's **harness** (``PANOPTICON_HARNESS``, recorded on the +task; default claude) — all CLI-specific mechanics live behind +:class:`~panopticon.harnesses.Harness`; this launcher just fetches the workflow data, hands it +to the harness, and launches. This is the only LLM-bearing path (the determinism invariant): +the **bootstrap** is deterministic and unit-tested with fakes; the **launch** (the real CLI) is injectable and only runs for real in a `skipif`-gated integration / a live container — never in CI. -Auth is the ``CLAUDE_CODE_OAUTH_TOKEN`` env var the runner injects from the repo's ``env_file``; -the launcher does no credential wiring of its own. +Auth is harness-specific env/files the runner injects from the repo's secrets (ADR 0007); the +launcher only asks the harness whether they're present and fails the spawn with the harness's +own message when not. 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. @@ -14,7 +18,6 @@ from __future__ import annotations -import json import os import signal import subprocess @@ -24,160 +27,20 @@ import httpx from panopticon.client import TaskServiceClient -from panopticon.container.config import update_json_config -from panopticon.container.hooks import write_settings -from panopticon.container.skills import write_commands, write_operation_commands from panopticon.core.models import Skill +from panopticon.harnesses import BootstrapContext, Harness, LaunchContext, get_harness -#: claude's main config file. Holds (besides per-container state) per-project trust acceptance. -CONFIG_FILE = ".claude.json" +def _run_agent( + harness: Harness, ctx: LaunchContext +) -> None: # pragma: no cover - real LLM; skipif-gated / live only + """Run the harness's CLI (resuming the session if any) in the foreground; return when it + exits. -#: Filename of the rendered MCP client config in the config dir; claude is pointed at it via -#: ``--mcp-config`` so it connects to the task service's MCP server (task operations as tools). -MCP_CONFIG_FILE = "panopticon-mcp.json" -#: Filename of the rendered workflow overview (the whole-lifecycle map); claude gets its contents in -#: the system prompt via ``--append-system-prompt`` so the agent always knows the workflow's shape. -WORKFLOW_OVERVIEW_FILE = "workflow-overview.md" - - -def render_skills(client: TaskServiceClient, task_id: str, home: Path) -> list[Path]: - """Render the active workflow's skills to the agent CLI surface (`.claude/commands/`).""" - skills = [Skill(**s) for s in client.list_skills(task_id)] - return write_commands(skills, home, task_id) - - -def render_operations(client: TaskServiceClient, task_id: str, home: Path) -> list[Path]: - """Render the active workflow's available core operations (advance/drop/…) as slash-commands. - - Reflects the *active workflow's* declared moves (ADR 0004), so a github-peer-reviewed and a - free-form container expose different operation commands — not a fixed global menu. - """ - return write_operation_commands(client.list_operations(task_id), home, task_id) - - -def write_mcp_config(config_dir: Path, service_url: str) -> Path: - """Write claude's MCP client config so it connects to the task service's MCP server. - - A single ``panopticon`` HTTP server at ``/mcp`` — the same control plane the - container already polls (``PANOPTICON_SERVICE_URL``, the in-container view). Returns the path, - which the launcher passes to ``claude --mcp-config``.""" - config_dir.mkdir(parents=True, exist_ok=True) - path = config_dir / MCP_CONFIG_FILE - server = {"type": "http", "url": f"{service_url.rstrip('/')}/mcp"} - path.write_text(json.dumps({"mcpServers": {"panopticon": server}}, indent=2)) - return path - - -def write_workflow_overview(config_dir: Path, overview: str) -> Path | None: - """Write the whole-workflow map so the launcher can put it in claude's system prompt. Returns the - path, or ``None`` when there's no overview (skipped — the agent just gets the per-turn briefing).""" - if not overview.strip(): - return None - config_dir.mkdir(parents=True, exist_ok=True) - path = config_dir / WORKFLOW_OVERVIEW_FILE - path.write_text(overview) - return path - - -def trust_workspace(config_dir: Path, cwd: Path) -> Path: - """Pre-accept claude's first-run dialogs for ``cwd``. - - Three blockers fire on a fresh container and must be pre-seeded — there is no operator in the - container to dismiss them interactively: - - - ``hasCompletedOnboarding`` — the general onboarding screen. - - ``projects[].hasTrustDialogAccepted`` — "Do you trust the files in this folder?" - (cf. claude issue #45298; separate from ``--dangerously-skip-permissions``). - - ``hasAcknowledgedCostThreshold`` — cost-acknowledgment dialog shown when authenticating - via ``ANTHROPIC_API_KEY`` (not shown for OAuth tokens). - - Merge-in-place so we don't clobber config claude writes itself, and idempotent. The path - encoding is undocumented internals — a safe degradation if it ever drifts is that the dialog - reappears, which only matters in an (already attended) interactive re-attach. - """ - config = config_dir / CONFIG_FILE - with update_json_config(config) as data: - data["hasCompletedOnboarding"] = True - data["hasAcknowledgedCostThreshold"] = True - projects = data.setdefault("projects", {}) - projects.setdefault(str(cwd), {})["hasTrustDialogAccepted"] = True - return config - - -#: Sent to claude as the first message when a container restarts mid-task on the agent's turn. -INTERRUPT_PROMPT = "You were interrupted. Continue." - - -def _claude_argv( - config_dir: Path, - cwd: Path, - *, - initial_prompt: str | None = None, - turn: str | None = None, - starting_model: str | None = None, -) -> list[str]: - """`claude` argv, resuming the project's most recent conversation if one exists. - - The agent runs unattended in a throwaway container on a per-task clone, so it launches with - ``--dangerously-skip-permissions`` — there's no operator to answer permission prompts, and the - blast radius is the task's own checkout. claude keeps per-project transcripts under - ``/projects/``; when one is there we ``--continue`` it instead of - starting fresh. The config dir is a **per-task volume** (the runner mounts it; ``CONFIG_MOUNT``), - so this resumes both within a container's life and **across respawn/recreate** — claude history - persists even though the container layer is thrown away. If our path encoding ever misses - claude's, we simply start fresh — a safe degradation. - - On a **first run** (no prior session) with an ``initial_prompt``, the prompt is appended as a - positional argument so claude processes it immediately. On a **resumed session** (``--continue``) - the ``initial_prompt`` is omitted — the agent is already mid-task. When the resumed session is - the agent's turn (``turn == "agent"``), :data:`INTERRUPT_PROMPT` is appended instead so the - agent automatically picks up where it left off rather than waiting for user input. - - ``starting_model`` (e.g. ``"opus"``) is passed as ``--model`` on the **first run only** — on - resume claude uses whichever model the conversation was already using. - """ - argv = ["claude", "--dangerously-skip-permissions"] - overview = config_dir / WORKFLOW_OVERVIEW_FILE - if overview.exists(): # the whole-workflow map → claude's system prompt (so it knows the shape) - argv += ["--append-system-prompt", overview.read_text()] - mcp_config = config_dir / MCP_CONFIG_FILE - if mcp_config.exists(): # connect to the task service's MCP server, and *only* it - argv += ["--mcp-config", str(mcp_config), "--strict-mcp-config"] - project = config_dir / "projects" / str(cwd).replace("/", "-") - if any(project.glob("*.jsonl")): - argv.append("--continue") - if turn == "agent": - argv.append(INTERRUPT_PROMPT) # positional: auto-resume after container restart - else: - if ( - starting_model - ): # first run only — on resume claude uses the conversation's existing model - argv += ["--model", starting_model] - if initial_prompt: - argv.append( - initial_prompt - ) # positional: claude sends this as the agent's first message - return argv - - -def _run_claude(config_dir: Path) -> None: # pragma: no cover - real LLM; skipif-gated / live only - """Run `claude` (resuming the session if any) in the foreground; return when it exits. - - Unlike an ``exec``, this returns control to :func:`main` when claude exits, so it can stop the - container (the task → down → respawn). claude inherits this pane's TTY (it's the interactive - surface ``tmux attach`` reaches).""" - initial_prompt = os.environ.get("PANOPTICON_INITIAL_PROMPT") or None - turn = os.environ.get("PANOPTICON_TASK_TURN") or None - starting_model = os.environ.get("PANOPTICON_STARTING_MODEL") or None - argv = _claude_argv( - config_dir, - Path.cwd(), - initial_prompt=initial_prompt, - turn=turn, - starting_model=starting_model, - ) - subprocess.run(argv, env={**os.environ, "CLAUDE_CONFIG_DIR": str(config_dir)}) + Unlike an ``exec``, this returns control to :func:`main` when the agent exits, so it can stop + the container (the task → down → respawn). The CLI inherits this pane's TTY (it's the + interactive surface ``tmux attach`` reaches).""" + subprocess.run(harness.argv(ctx), env={**os.environ, **harness.env(ctx)}) def _stop_container() -> None: # pragma: no cover - signals the real container's PID 1 @@ -196,39 +59,48 @@ def main( *, client_factory: Callable[[str], TaskServiceClient] = _default_client, home: Path | None = None, - launch: Callable[[Path], None] = _run_claude, + launch: Callable[[Harness, LaunchContext], None] = _run_agent, on_exit: Callable[[], None] = _stop_container, ) -> None: - """Bootstrap the agent CLI from the active workflow (skills + turn-flip hooks), run the agent, - then stop the container when it exits. The CLI config dir is a per-task volume - (`/.claude`); auth comes from the ``CLAUDE_CODE_OAUTH_TOKEN`` env var the runner injects. + """Bootstrap the task's harness from the active workflow (skills + turn-flip hooks), run the + agent, then stop the container when it exits. The CLI's config dir is a per-task volume + (``/``); auth comes from the env/files the runner injects. - When the agent (claude) exits, ``on_exit`` stops the container so the task goes **down** rather + When the agent exits, ``on_exit`` stops the container so the task goes **down** rather than lingering live-but-unconnectable — the operator respawns it with `R` (history resumes).""" env = os.environ service_url = env["PANOPTICON_SERVICE_URL"] client = client_factory(service_url) - config_dir = (home or Path.home()) / ".claude" + harness = get_harness(env.get("PANOPTICON_HARNESS")) + home = home or Path.home() task_id = env["PANOPTICON_TASK_ID"] runner_id = env.get("PANOPTICON_RUNNER_ID") - if not env.get("CLAUDE_CODE_OAUTH_TOKEN") and not env.get("ANTHROPIC_API_KEY"): + if detail := harness.missing_auth(env, home=home): if runner_id: - client.report_lifecycle( - task_id, - runner_id, - phase="failed", - detail="No auth token — set CLAUDE_CODE_OAUTH_TOKEN in the repo's env_file (see docs/auth.md)", - ) + client.report_lifecycle(task_id, runner_id, phase="failed", detail=detail) return - render_skills(client, task_id, config_dir.parent) - render_operations(client, task_id, config_dir.parent) # advance/drop/… as slash-commands - write_settings(config_dir.parent) # turn-flip hooks → /.claude/settings.json - write_mcp_config(config_dir, service_url) # point claude at the task service's MCP server - write_workflow_overview( - config_dir, client.workflow_overview(task_id) - ) # → system prompt (the map) - trust_workspace(config_dir, Path.cwd()) # pre-accept the trust dialog (no operator to) - launch(config_dir) # the agent runs until it exits... + harness.bootstrap( + BootstrapContext( + home=home, + cwd=Path.cwd(), + service_url=service_url, + task_id=task_id, + skills=[Skill(**s) for s in client.list_skills(task_id)], + operations=client.list_operations(task_id), + overview=client.workflow_overview(task_id), + environ=env, + ) + ) + launch( + harness, + LaunchContext( + home=home, + cwd=Path.cwd(), + initial_prompt=env.get("PANOPTICON_INITIAL_PROMPT") or None, + turn=env.get("PANOPTICON_TASK_TURN") or None, + starting_model=env.get("PANOPTICON_STARTING_MODEL") or None, + ), + ) # the agent runs until it exits... on_exit() # ...then stop the container (task → down → respawn) diff --git a/src/panopticon/container/hooks.py b/src/panopticon/container/hooks.py deleted file mode 100644 index 8e521b69..00000000 --- a/src/panopticon/container/hooks.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Render the claude hook settings that wire the **turn-flip contract** (Slice 4): - -- the agent's **Stop** hook flips the live turn to the *user* (the agent handed the ball back) — - *unless* a background task is still running, in which case the callback leaves the turn on the - agent (see :mod:`panopticon.container.hook`), since the task's completion re-invokes the agent - without a UserPromptSubmit; -- **UserPromptSubmit** flips it back to the *agent* (the user replied); -- **PreToolUse**/**PostToolUse** matched to the ``AskUserQuestion`` tool flip to the *user* while - the agent is asking the user something (so the dashboard shows input is required) and back to the - *agent* once it's answered. ``AskUserQuestion`` is a mid-turn tool call — it never fires ``Stop`` - — so without this the turn would wrongly read *agent* the whole time the question is pending. - -claude-specific (`.claude/settings.json`); M3 revisits for other CLIs. Pure — the callback the -hooks invoke is :mod:`panopticon.container.hook`. `:blocked:` is preserved by construction: the -callback only sets the turn, never the block. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from panopticon.container.config import update_json_config - -#: The command claude runs for each hook event (sets the turn via the task service). -HOOK_COMMAND = "python -m panopticon.container.hook" - - -def settings() -> dict[str, Any]: - """The `.claude/settings.json` we seed: the turn-flip hooks **and** a pre-accept of Bypass - Permissions mode. - - The agent launches with ``--dangerously-skip-permissions`` (no operator to answer prompts), but - on a fresh config dir claude first stops on an interactive *"Bypass Permissions mode … 1. No, - exit / 2. Yes, I accept"* gate — which hangs the container forever (the task shows "stuck - starting"). claude records that acceptance as ``skipDangerousModePermissionPrompt`` in this file, - so seeding it ``True`` up front pre-accepts the gate and claude goes straight to work. - """ - - def run(actor: str, event: str | None = None, *, matcher: str | None = None) -> dict[str, Any]: - # `actor` is the turn to set; the optional `event` selects the callback's side-effect - # (briefing on the prompt hook, token report on stop) — the bare question hooks pass none. - command = f"{HOOK_COMMAND} {actor}" + (f" {event}" if event else "") - entry: dict[str, Any] = {"hooks": [{"type": "command", "command": command}]} - if ( - matcher is not None - ): # PreToolUse/PostToolUse are tool-scoped; Stop/UserPromptSubmit aren't - entry["matcher"] = matcher - return entry - - return { - "hooks": { - "Stop": [run("user", "stop")], - "UserPromptSubmit": [run("agent", "prompt")], - # The agent stops to ask the user → flip to user; once answered → back to agent. - "PreToolUse": [run("user", matcher="AskUserQuestion")], - "PostToolUse": [run("agent", matcher="AskUserQuestion")], - }, - "skipDangerousModePermissionPrompt": True, - } - - -def write_settings(home: Path) -> Path: - """Merge the turn-flip hooks into ``/.claude/settings.json``; return the path.""" - path = home / ".claude" / "settings.json" - with update_json_config(path) as data: - data.update(settings()) - return path diff --git a/src/panopticon/container/skills.py b/src/panopticon/container/skills.py deleted file mode 100644 index 5ed69082..00000000 --- a/src/panopticon/container/skills.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Render a workflow's :class:`~panopticon.core.models.Skill` specs to the claude CLI surface. - -The Skill spec is agent-CLI-agnostic (core, ADR 0004); this is the **claude-specific renderer** -(M3 adds others): a skill becomes a `.claude/commands/.md` slash-command the agent's CLI -picks up. Pure — no LLM; it just writes files. The in-container harness fetches the active -workflow's skills (over REST) and renders them before launching the agent (Slice 6c). -""" - -from __future__ import annotations - -from collections.abc import Iterable, Mapping -from pathlib import Path - -from panopticon.core.models import Skill - - -# The panopticon MCP tools all take a ``task_id`` (the server is shared across tasks). The agent -# can't read its container's env, so we inject the concrete id into each rendered command — the -# agent passes this verbatim. (Identity is a container-side fact; ARCHITECTURE §8.3.) -def _task_id_note(task_id: str) -> str: - return ( - f'\nThis is task `{task_id}` — pass `task_id="{task_id}"` to every panopticon MCP tool ' - f"you call here.\n" - ) - - -def render_command(skill: Skill, task_id: str) -> str: - """The `.claude/commands/.md` body for a skill: frontmatter + the agent procedure.""" - return f"---\ndescription: {skill.description}\n---\n{skill.instructions}\n{_task_id_note(task_id)}" - - -def render_operation(name: str, target_state: str, task_id: str) -> str: - """The `.claude/commands/.md` body for a core operation (advance/drop/…). - - Operations are the workflow's **declared, gated** moves; the agent applies one by name via the - `apply_operation` tool (not by editing state directly), which starts a new agentic turn. - """ - return ( - f"---\ndescription: Apply the workflow's '{name}' operation.\n---\n" - f"Apply this workflow's `{name}` operation — it moves the task to **{target_state}**. " - f'Invoke it with the `apply_operation` tool (`operation="{name}"`, `task_id="{task_id}"`); ' - f"don't edit the state directly. It's gated on the current state's responsibilities and " - f"starts a new turn.\n" - ) - - -def write_commands(skills: Iterable[Skill], root: Path, task_id: str) -> list[Path]: - """Write each skill to ``/.claude/commands/.md``; return the paths written.""" - commands_dir = root / ".claude" / "commands" - commands_dir.mkdir(parents=True, exist_ok=True) - written = [] - for skill in skills: - path = commands_dir / f"{skill.name}.md" - path.write_text(render_command(skill, task_id)) - written.append(path) - return written - - -def write_operation_commands(operations: Mapping[str, str], root: Path, task_id: str) -> list[Path]: - """Write each core operation (verb → target state) to ``/.claude/commands/.md``.""" - commands_dir = root / ".claude" / "commands" - commands_dir.mkdir(parents=True, exist_ok=True) - written = [] - for name, target_state in operations.items(): - path = commands_dir / f"{name}.md" - path.write_text(render_operation(name, target_state, task_id)) - written.append(path) - return written diff --git a/src/panopticon/harnesses/__init__.py b/src/panopticon/harnesses/__init__.py new file mode 100644 index 00000000..d6d61d62 --- /dev/null +++ b/src/panopticon/harnesses/__init__.py @@ -0,0 +1,47 @@ +"""Agent-CLI harnesses (Milestone 3): the registry of pluggable in-container agent runtimes. + +A task records which harness runs it (:attr:`~panopticon.core.models.Task.harness`, an opaque +string); the container's agent launcher and the session service look the mechanics up here. +The registry is a literal mapping — path discovery (the ``workflows`` treatment) waits until +operator-authored harnesses are a real use, not a hypothetical.""" + +from __future__ import annotations + +from collections.abc import Mapping + +from panopticon.harnesses.base import ( + CREDENTIALS_MOUNT, + INTERRUPT_PROMPT, + BootstrapContext, + Harness, + LaunchContext, +) +from panopticon.harnesses.claude import ClaudeHarness + +#: The default when a task records no harness — the surface panopticon launched with. +DEFAULT_HARNESS = "claude" + +HARNESSES: Mapping[str, Harness] = {h.name: h for h in (ClaudeHarness(),)} + + +def get_harness(name: str | None) -> Harness: + """The harness registered as ``name`` (``None`` → the default). Raises ``KeyError`` with the + known names for an unknown one — task creation validates against this, so a spawn never + discovers an unknown harness.""" + key = name or DEFAULT_HARNESS + try: + return HARNESSES[key] + except KeyError: + raise KeyError(f"unknown harness {key!r} (known: {', '.join(sorted(HARNESSES))})") from None + + +__all__ = [ + "CREDENTIALS_MOUNT", + "DEFAULT_HARNESS", + "HARNESSES", + "INTERRUPT_PROMPT", + "BootstrapContext", + "Harness", + "LaunchContext", + "get_harness", +] diff --git a/src/panopticon/harnesses/base.py b/src/panopticon/harnesses/base.py new file mode 100644 index 00000000..7d41adf4 --- /dev/null +++ b/src/panopticon/harnesses/base.py @@ -0,0 +1,108 @@ +"""The ``Harness`` interface — the agent CLI a task container runs, as a pluggable adapter. + +A harness *describes and renders* one agent CLI's surface (ADR 0004's Skill specs, the +turn-flip hook wiring, MCP client config, system-prompt injection, launch argv) — it never +runs the CLI itself. The only place a harness's :meth:`Harness.argv` is *executed* is the +in-container agent launcher (:mod:`panopticon.container.agent`), which keeps the determinism +invariant's shape: this package writes files and computes argv (deterministic, unit-tested); +``container/`` remains the only LLM-bearing path. + +Like the execution-backend ``Runner`` (and unlike ``Store``/``Workflow``), the interface lives +in its owning package rather than ``core``: harnesses aren't a control-plane dependency — the +task service only records a task's harness *name* (an opaque fact); the container and the +session service look the name up here when they need the CLI's mechanics. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import ClassVar + +from panopticon.core.models import Skill + +#: Sent to the agent as the first message when a container restarts mid-task on the agent's +#: turn, so it picks up where it left off rather than waiting for user input. +INTERRUPT_PROMPT = "You were interrupted. Continue." + +#: The in-container path of the mounted per-repo credential dir (the repo's ``credential_dir`` +#: secrets reference, ADR 0007's directory-shaped sibling of ``env_file``). Shared read-write +#: across the repo's task containers — it holds credentials whose nature is shared (e.g. one +#: ChatGPT account = one rotating token chain, converged on by every session). The runner +#: exports the path as ``PANOPTICON_CREDENTIALS`` when the mount is present. +CREDENTIALS_MOUNT = "/panopticon/credentials" + + +@dataclass(frozen=True) +class BootstrapContext: + """Everything a harness needs to render its CLI surface — plain data, no client. + + The agent launcher fetches the active workflow's skills/operations/overview from the task + service and passes them in, so a harness's :meth:`Harness.bootstrap` stays a pure function + of its inputs (deterministic, testable with no service). + """ + + home: Path # the in-container user's home (config dirs hang off it) + cwd: Path # the task workspace (the per-task clone) + service_url: str # the task service as seen from the container + task_id: str + skills: Sequence[Skill] = () + operations: Mapping[str, str] = field(default_factory=dict) # verb → target state + overview: str = "" # the whole-workflow map (→ the agent's system prompt) + environ: Mapping[str, str] = field(default_factory=dict) # the container's env (auth vars) + + +@dataclass(frozen=True) +class LaunchContext: + """What the launch argv depends on: first-run inputs and the resume signal.""" + + home: Path + cwd: Path + initial_prompt: str | None = None # first message on a first run (unset on resume) + turn: str | None = None # "agent" → auto-resume with INTERRUPT_PROMPT on respawn + starting_model: str | None = None # model for the first run; resumes keep the session's + + +class Harness(ABC): + """One agent CLI's mechanics. Subclasses are stateless; the registry holds one instance each. + + ``name`` is what :attr:`~panopticon.core.models.Task.harness` records — the control plane + treats it as an opaque string; everything CLI-specific stays behind this interface. + ``config_dirname`` names the per-task config-volume mountpoint under the container home + (e.g. ``.claude``), where the CLI keeps the session state that must survive respawn. + """ + + name: ClassVar[str] + config_dirname: ClassVar[str] + + def config_dir(self, home: Path) -> Path: + """The CLI's config dir under ``home`` — the per-task volume's in-container path.""" + return home / self.config_dirname + + def image_layer(self) -> str: + """Dockerfile fragment installing this CLI, composed as the **harness tier** of the task + image (base → harness → workflow → repo, ADR 0005). Empty when the base image already + carries the CLI (claude, today).""" + return "" + + @abstractmethod + def missing_auth(self, environ: Mapping[str, str], *, home: Path) -> str | None: + """``None`` when the container can authenticate; else the operator-facing message the + spawn failure carries (naming the exact variable/file to set for *this* harness).""" + + @abstractmethod + def bootstrap(self, ctx: BootstrapContext) -> None: + """Render the CLI's surface: skills/operations, turn-flip hook wiring, MCP client + config, the workflow overview, and any first-run acceptance seeds. Pure file writes, + idempotent — it runs on every container start (including respawns).""" + + @abstractmethod + def argv(self, ctx: LaunchContext) -> list[str]: + """The CLI's launch argv — resuming the prior session when the config volume holds one, + else a first run carrying ``starting_model``/``initial_prompt``.""" + + def env(self, ctx: LaunchContext) -> dict[str, str]: + """Extra environment for the launch (e.g. pointing the CLI at its config dir).""" + return {} diff --git a/src/panopticon/harnesses/claude.py b/src/panopticon/harnesses/claude.py new file mode 100644 index 00000000..5a63fbd0 --- /dev/null +++ b/src/panopticon/harnesses/claude.py @@ -0,0 +1,259 @@ +"""The claude harness — Anthropic's ``claude`` CLI as the (default) agent runtime. + +Consolidates the claude-specific mechanics that used to live across ``container/agent.py`` +(argv, MCP config, workflow overview, trust seeds), ``container/skills.py`` (slash-command +rendering), and ``container/hooks.py`` (turn-flip hook settings). Everything here is pure file +writes and argv computation; the launch happens in the agent launcher. + +Auth is the ``CLAUDE_CODE_OAUTH_TOKEN`` env var the runner injects from the repo's ``env_file`` +(or an ``ANTHROPIC_API_KEY``); see ``docs/auth.md``. The claude CLI itself ships in the base +image, so this harness contributes no image layer. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any, ClassVar + +from panopticon.core.models import Skill +from panopticon.harnesses.base import INTERRUPT_PROMPT, BootstrapContext, Harness, LaunchContext +from panopticon.harnesses.config import update_json_config + +#: claude's main config file. Holds (besides per-container state) per-project trust acceptance. +CONFIG_FILE = ".claude.json" + +#: Filename of the rendered MCP client config in the config dir; claude is pointed at it via +#: ``--mcp-config`` so it connects to the task service's MCP server (task operations as tools). +MCP_CONFIG_FILE = "panopticon-mcp.json" +#: Filename of the rendered workflow overview (the whole-lifecycle map); claude gets its contents in +#: the system prompt via ``--append-system-prompt`` so the agent always knows the workflow's shape. +WORKFLOW_OVERVIEW_FILE = "workflow-overview.md" + +#: The command claude runs for each hook event (sets the turn via the task service). The callback +#: module stays in ``container/`` — it runs inside the container and talks REST at hook time. +HOOK_COMMAND = "python -m panopticon.container.hook" + + +# The panopticon MCP tools all take a ``task_id`` (the server is shared across tasks). The agent +# can't read its container's env, so we inject the concrete id into each rendered command — the +# agent passes this verbatim. (Identity is a container-side fact; ARCHITECTURE §8.3.) +def _task_id_note(task_id: str) -> str: + return ( + f'\nThis is task `{task_id}` — pass `task_id="{task_id}"` to every panopticon MCP tool ' + f"you call here.\n" + ) + + +def render_command(skill: Skill, task_id: str) -> str: + """The `.claude/commands/.md` body for a skill: frontmatter + the agent procedure.""" + return f"---\ndescription: {skill.description}\n---\n{skill.instructions}\n{_task_id_note(task_id)}" + + +def render_operation(name: str, target_state: str, task_id: str) -> str: + """The `.claude/commands/.md` body for a core operation (advance/drop/…). + + Operations are the workflow's **declared, gated** moves; the agent applies one by name via the + `apply_operation` tool (not by editing state directly), which starts a new agentic turn. + """ + return ( + f"---\ndescription: Apply the workflow's '{name}' operation.\n---\n" + f"Apply this workflow's `{name}` operation — it moves the task to **{target_state}**. " + f'Invoke it with the `apply_operation` tool (`operation="{name}"`, `task_id="{task_id}"`); ' + f"don't edit the state directly. It's gated on the current state's responsibilities and " + f"starts a new turn.\n" + ) + + +def write_commands(skills: Iterable[Skill], root: Path, task_id: str) -> list[Path]: + """Write each skill to ``/.claude/commands/.md``; return the paths written.""" + commands_dir = root / ".claude" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + written = [] + for skill in skills: + path = commands_dir / f"{skill.name}.md" + path.write_text(render_command(skill, task_id)) + written.append(path) + return written + + +def write_operation_commands(operations: Mapping[str, str], root: Path, task_id: str) -> list[Path]: + """Write each core operation (verb → target state) to ``/.claude/commands/.md``.""" + commands_dir = root / ".claude" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + written = [] + for name, target_state in operations.items(): + path = commands_dir / f"{name}.md" + path.write_text(render_operation(name, target_state, task_id)) + written.append(path) + return written + + +def settings() -> dict[str, Any]: + """The `.claude/settings.json` we seed: the turn-flip hooks **and** a pre-accept of Bypass + Permissions mode. + + - the agent's **Stop** hook flips the live turn to the *user* (the agent handed the ball + back) — *unless* a background task is still running, in which case the callback leaves the + turn on the agent (see :mod:`panopticon.container.hook`); + - **UserPromptSubmit** flips it back to the *agent* (the user replied); + - **PreToolUse**/**PostToolUse** matched to ``AskUserQuestion`` flip to the *user* while the + agent is asking the user something and back to the *agent* once it's answered. + + The agent launches with ``--dangerously-skip-permissions`` (no operator to answer prompts), but + on a fresh config dir claude first stops on an interactive *"Bypass Permissions mode … 1. No, + exit / 2. Yes, I accept"* gate — which hangs the container forever (the task shows "stuck + starting"). claude records that acceptance as ``skipDangerousModePermissionPrompt`` in this file, + so seeding it ``True`` up front pre-accepts the gate and claude goes straight to work. + """ + + def run(actor: str, event: str | None = None, *, matcher: str | None = None) -> dict[str, Any]: + # `actor` is the turn to set; the optional `event` selects the callback's side-effect + # (briefing on the prompt hook, token report on stop) — the bare question hooks pass none. + command = f"{HOOK_COMMAND} {actor}" + (f" {event}" if event else "") + entry: dict[str, Any] = {"hooks": [{"type": "command", "command": command}]} + if ( + matcher is not None + ): # PreToolUse/PostToolUse are tool-scoped; Stop/UserPromptSubmit aren't + entry["matcher"] = matcher + return entry + + return { + "hooks": { + "Stop": [run("user", "stop")], + "UserPromptSubmit": [run("agent", "prompt")], + # The agent stops to ask the user → flip to user; once answered → back to agent. + "PreToolUse": [run("user", matcher="AskUserQuestion")], + "PostToolUse": [run("agent", matcher="AskUserQuestion")], + }, + "skipDangerousModePermissionPrompt": True, + } + + +def write_settings(home: Path) -> Path: + """Merge the turn-flip hooks into ``/.claude/settings.json``; return the path.""" + path = home / ".claude" / "settings.json" + with update_json_config(path) as data: + data.update(settings()) + return path + + +def write_mcp_config(config_dir: Path, service_url: str) -> Path: + """Write claude's MCP client config so it connects to the task service's MCP server. + + A single ``panopticon`` HTTP server at ``/mcp`` — the same control plane the + container already polls (``PANOPTICON_SERVICE_URL``, the in-container view). Returns the path, + which the launcher passes to ``claude --mcp-config``.""" + import json + + config_dir.mkdir(parents=True, exist_ok=True) + path = config_dir / MCP_CONFIG_FILE + server = {"type": "http", "url": f"{service_url.rstrip('/')}/mcp"} + path.write_text(json.dumps({"mcpServers": {"panopticon": server}}, indent=2)) + return path + + +def write_workflow_overview(config_dir: Path, overview: str) -> Path | None: + """Write the whole-workflow map so the launcher can put it in claude's system prompt. Returns the + path, or ``None`` when there's no overview (skipped — the agent just gets the per-turn briefing).""" + if not overview.strip(): + return None + config_dir.mkdir(parents=True, exist_ok=True) + path = config_dir / WORKFLOW_OVERVIEW_FILE + path.write_text(overview) + return path + + +def trust_workspace(config_dir: Path, cwd: Path) -> Path: + """Pre-accept claude's first-run dialogs for ``cwd``. + + Three blockers fire on a fresh container and must be pre-seeded — there is no operator in the + container to dismiss them interactively: + + - ``hasCompletedOnboarding`` — the general onboarding screen. + - ``projects[].hasTrustDialogAccepted`` — "Do you trust the files in this folder?" + (cf. claude issue #45298; separate from ``--dangerously-skip-permissions``). + - ``hasAcknowledgedCostThreshold`` — cost-acknowledgment dialog shown when authenticating + via ``ANTHROPIC_API_KEY`` (not shown for OAuth tokens). + + Merge-in-place so we don't clobber config claude writes itself, and idempotent. The path + encoding is undocumented internals — a safe degradation if it ever drifts is that the dialog + reappears, which only matters in an (already attended) interactive re-attach. + """ + config = config_dir / CONFIG_FILE + with update_json_config(config) as data: + data["hasCompletedOnboarding"] = True + data["hasAcknowledgedCostThreshold"] = True + projects = data.setdefault("projects", {}) + projects.setdefault(str(cwd), {})["hasTrustDialogAccepted"] = True + return config + + +class ClaudeHarness(Harness): + """The default harness: ``claude`` with the surface Slice 6 built, behind the M3 interface.""" + + name: ClassVar[str] = "claude" + config_dirname: ClassVar[str] = ".claude" + + def missing_auth(self, environ: Mapping[str, str], *, home: Path) -> str | None: + if environ.get("CLAUDE_CODE_OAUTH_TOKEN") or environ.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 bootstrap(self, ctx: BootstrapContext) -> None: + config_dir = self.config_dir(ctx.home) + write_commands(ctx.skills, ctx.home, ctx.task_id) + write_operation_commands(ctx.operations, ctx.home, ctx.task_id) # advance/drop/… + write_settings(ctx.home) # turn-flip hooks → /.claude/settings.json + write_mcp_config(config_dir, ctx.service_url) # point claude at the task service's MCP + write_workflow_overview(config_dir, ctx.overview) # → system prompt (the map) + trust_workspace(config_dir, ctx.cwd) # pre-accept the trust dialog (no operator to) + + def argv(self, ctx: LaunchContext) -> list[str]: + """`claude` argv, resuming the project's most recent conversation if one exists. + + The agent runs unattended in a throwaway container on a per-task clone, so it launches with + ``--dangerously-skip-permissions`` — there's no operator to answer permission prompts, and + the blast radius is the task's own checkout. claude keeps per-project transcripts under + ``/projects/``; when one is there we ``--continue`` it instead + of starting fresh. The config dir is a **per-task volume**, so this resumes both within a + container's life and **across respawn/recreate**. If our path encoding ever misses + claude's, we simply start fresh — a safe degradation. + + On a **first run** (no prior session) with an ``initial_prompt``, the prompt is appended as + a positional argument so claude processes it immediately. On a **resumed session** + (``--continue``) the ``initial_prompt`` is omitted — the agent is already mid-task. When + the resumed session is the agent's turn, :data:`INTERRUPT_PROMPT` is appended instead so + the agent automatically picks up where it left off rather than waiting for user input. + + ``starting_model`` (e.g. ``"opus"``) is passed as ``--model`` on the **first run only** — + on resume claude uses whichever model the conversation was already using. + """ + config_dir = self.config_dir(ctx.home) + argv = ["claude", "--dangerously-skip-permissions"] + overview = config_dir / WORKFLOW_OVERVIEW_FILE + if overview.exists(): # the whole-workflow map → claude's system prompt + argv += ["--append-system-prompt", overview.read_text()] + mcp_config = config_dir / MCP_CONFIG_FILE + if mcp_config.exists(): # connect to the task service's MCP server, and *only* it + argv += ["--mcp-config", str(mcp_config), "--strict-mcp-config"] + project = config_dir / "projects" / str(ctx.cwd).replace("/", "-") + if any(project.glob("*.jsonl")): + argv.append("--continue") + if ctx.turn == "agent": + argv.append(INTERRUPT_PROMPT) # positional: auto-resume after container restart + else: + if ( + ctx.starting_model + ): # first run only — on resume claude uses the conversation's existing model + argv += ["--model", ctx.starting_model] + if ctx.initial_prompt: + argv.append( + ctx.initial_prompt + ) # positional: claude sends this as the agent's first message + return argv + + def env(self, ctx: LaunchContext) -> dict[str, str]: + return {"CLAUDE_CONFIG_DIR": str(self.config_dir(ctx.home))} diff --git a/src/panopticon/container/config.py b/src/panopticon/harnesses/config.py similarity index 85% rename from src/panopticon/container/config.py rename to src/panopticon/harnesses/config.py index 0c7a3a4f..44cc5239 100644 --- a/src/panopticon/container/config.py +++ b/src/panopticon/harnesses/config.py @@ -1,9 +1,9 @@ -"""The single place we touch claude's on-disk JSON config (`.claude.json`, `.claude/settings.json`). +"""The single place harness renderers touch a CLI's on-disk JSON config (e.g. `.claude.json`, +`.claude/settings.json`, codex's `auth.json`). A **read-merge-write** so a caller states only the keys it cares about and never clobbers the rest: load whatever's already there (or start empty), let the caller mutate it in the ``with`` -block, then write it back with stable 2-space indentation on a clean exit. claude-specific, like -its callers (M3 revisits for other CLIs). +block, then write it back with stable 2-space indentation on a clean exit. """ from __future__ import annotations diff --git a/tests/container/test_agent.py b/tests/container/test_agent.py index 748075ee..9ec6f81a 100644 --- a/tests/container/test_agent.py +++ b/tests/container/test_agent.py @@ -1,5 +1,5 @@ -"""The in-container agent launcher: the deterministic bootstrap (render the workflow's skills + -turn-flip hooks, link credentials) then launch. No LLM — the real `claude` exec is a fake here.""" +"""The in-container agent launcher: fetch the workflow surface, dispatch to the task's harness +(the deterministic bootstrap), then launch. No LLM — the real CLI launch is a fake here.""" from __future__ import annotations @@ -8,16 +8,18 @@ import pytest from panopticon.container import agent +from panopticon.harnesses import Harness, LaunchContext +from panopticon.harnesses.claude import MCP_CONFIG_FILE, WORKFLOW_OVERVIEW_FILE class _FakeClient: def __init__( self, - skills: list[dict[str, str]], + skills: list[dict[str, str]] | None = None, operations: dict[str, str] | None = None, overview: str = "# the workflow", ) -> None: - self._skills = skills + self._skills = skills or [] self._operations = operations or {} self._overview = overview self.lifecycle_calls: list[dict[str, str | None]] = [] @@ -40,176 +42,25 @@ def report_lifecycle( return {} -def test_render_skills_writes_command_files(tmp_path: Path) -> None: - client = _FakeClient( - [{"name": "babysit-ci", "description": "Watch CI.", "instructions": "loop"}] - ) - agent.render_skills(client, "t1", tmp_path) # type: ignore[arg-type] - assert ( - (tmp_path / ".claude" / "commands" / "babysit-ci.md") - .read_text() - .startswith("---\ndescription: Watch CI.") - ) - - -def test_render_operations_writes_a_command_per_operation(tmp_path: Path) -> None: - client = _FakeClient([], {"advance": "COMPLETE", "drop": "DROPPED"}) - agent.render_operations(client, "t1", tmp_path) # type: ignore[arg-type] - commands = tmp_path / ".claude" / "commands" - assert {p.name for p in commands.glob("*.md")} == {"advance.md", "drop.md"} - body = (commands / "advance.md").read_text() - assert "apply_operation" in body and "COMPLETE" in body # tells the agent how + the target - assert 'task_id="t1"' in body # the container's task id, injected for the MCP tool call - - -def test_claude_argv_starts_fresh_without_a_session(tmp_path: Path) -> None: - # Unattended container, per-task clone → skip permission prompts (no operator to answer them). - assert agent._claude_argv(tmp_path, Path("/work/repo")) == [ - "claude", - "--dangerously-skip-permissions", - ] - - -def test_claude_argv_continues_an_existing_session(tmp_path: Path) -> None: - project = tmp_path / "projects" / "-work-repo" # claude's /projects/ - project.mkdir(parents=True) - (project / "session.jsonl").write_text("{}") - assert agent._claude_argv(tmp_path, Path("/work/repo")) == [ - "claude", - "--dangerously-skip-permissions", - "--continue", - ] - - -def test_claude_argv_appends_initial_prompt_on_first_session(tmp_path: Path) -> None: - argv = agent._claude_argv(tmp_path, Path("/work/repo"), initial_prompt="review your plan") - assert argv == ["claude", "--dangerously-skip-permissions", "review your plan"] - - -def test_claude_argv_omits_initial_prompt_when_continuing_a_session(tmp_path: Path) -> None: - project = tmp_path / "projects" / "-work-repo" - project.mkdir(parents=True) - (project / "session.jsonl").write_text("{}") - argv = agent._claude_argv(tmp_path, Path("/work/repo"), initial_prompt="review your plan") - assert "--continue" in argv - assert "review your plan" not in argv - - -def test_claude_argv_appends_interrupt_prompt_on_respawn_for_agent_turn(tmp_path: Path) -> None: - project = tmp_path / "projects" / "-work-repo" - project.mkdir(parents=True) - (project / "session.jsonl").write_text("{}") - argv = agent._claude_argv(tmp_path, Path("/work/repo"), turn="agent") - assert argv == [ - "claude", - "--dangerously-skip-permissions", - "--continue", - agent.INTERRUPT_PROMPT, - ] - - -def test_claude_argv_omits_interrupt_prompt_on_respawn_for_user_turn(tmp_path: Path) -> None: - project = tmp_path / "projects" / "-work-repo" - project.mkdir(parents=True) - (project / "session.jsonl").write_text("{}") - argv = agent._claude_argv(tmp_path, Path("/work/repo"), turn="user") - assert argv == ["claude", "--dangerously-skip-permissions", "--continue"] - - -def test_write_mcp_config_points_claude_at_the_task_service_mcp(tmp_path: Path) -> None: - import json - - path = agent.write_mcp_config(tmp_path, "http://host.docker.internal:8000") - assert path == tmp_path / agent.MCP_CONFIG_FILE - cfg = json.loads(path.read_text()) - server = cfg["mcpServers"]["panopticon"] - assert server == {"type": "http", "url": "http://host.docker.internal:8000/mcp"} - - -def test_claude_argv_adds_strict_mcp_config_when_present(tmp_path: Path) -> None: - agent.write_mcp_config(tmp_path, "http://svc:8000") - argv = agent._claude_argv(tmp_path, Path("/work/repo")) - assert argv == [ - "claude", - "--dangerously-skip-permissions", - "--mcp-config", - str(tmp_path / agent.MCP_CONFIG_FILE), - "--strict-mcp-config", - ] - - -def test_write_workflow_overview_writes_the_map_else_skips(tmp_path: Path) -> None: - path = agent.write_workflow_overview(tmp_path, "# github-peer-reviewed\nphases…") - assert ( - path == tmp_path / agent.WORKFLOW_OVERVIEW_FILE - and path.read_text() == "# github-peer-reviewed\nphases…" - ) - assert agent.write_workflow_overview(tmp_path / "empty", " ") is None # no overview → skipped - - -def test_claude_argv_appends_the_workflow_overview_to_the_system_prompt(tmp_path: Path) -> None: - agent.write_workflow_overview(tmp_path, "# the workflow map") - argv = agent._claude_argv(tmp_path, Path("/work/repo")) - i = argv.index("--append-system-prompt") - assert ( - argv[i + 1] == "# the workflow map" - ) # the map's contents go inline into the system prompt - - -def test_claude_argv_passes_model_on_first_run(tmp_path: Path) -> None: - argv = agent._claude_argv(tmp_path, Path("/work/repo"), starting_model="opus") - assert argv == ["claude", "--dangerously-skip-permissions", "--model", "opus"] - - -def test_claude_argv_omits_model_on_resume(tmp_path: Path) -> None: - project = tmp_path / "projects" / "-work-repo" - project.mkdir(parents=True) - (project / "session.jsonl").write_text("{}") - argv = agent._claude_argv(tmp_path, Path("/work/repo"), starting_model="opus") - assert "--model" not in argv - assert "--continue" in argv - - -def test_claude_argv_passes_model_before_initial_prompt_on_first_run(tmp_path: Path) -> None: - argv = agent._claude_argv( - tmp_path, Path("/work/repo"), initial_prompt="start now", starting_model="opus" - ) - assert argv == ["claude", "--dangerously-skip-permissions", "--model", "opus", "start now"] - - -def test_trust_workspace_seeds_acceptance_for_a_fresh_config(tmp_path: Path) -> None: - import json - - config_dir = tmp_path / ".claude" - agent.trust_workspace(config_dir, Path("/workspace")) - data = json.loads((config_dir / ".claude.json").read_text()) - assert data["projects"]["/workspace"]["hasTrustDialogAccepted"] is True - assert data["hasCompletedOnboarding"] is True - assert data["hasAcknowledgedCostThreshold"] is True # suppresses the API-key cost dialog - - -def test_trust_workspace_merges_and_is_idempotent(tmp_path: Path) -> None: - import json - - config_dir = tmp_path / ".claude" - config_dir.mkdir() - # claude already wrote config (incl. an existing project) — we must not clobber it. - (config_dir / ".claude.json").write_text( - json.dumps({"userID": "u", "projects": {"/other": {"history": []}}}) - ) - agent.trust_workspace(config_dir, Path("/workspace")) - agent.trust_workspace(config_dir, Path("/workspace")) # idempotent - data = json.loads((config_dir / ".claude.json").read_text()) - assert data["userID"] == "u" # preserved - assert data["projects"]["/other"] == {"history": []} # preserved - assert data["projects"]["/workspace"]["hasTrustDialogAccepted"] is True - - -def test_main_bootstraps_into_a_container_local_config_dir_then_launches( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def _base_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PANOPTICON_SERVICE_URL", "http://svc") monkeypatch.setenv("PANOPTICON_TASK_ID", "t1") + for var in ( + "PANOPTICON_HARNESS", + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_API_KEY", + "CODEX_API_KEY", + "OPENAI_API_KEY", + "CODEX_ACCESS_TOKEN", + "PANOPTICON_CREDENTIALS", + ): + monkeypatch.delenv(var, raising=False) + + +def test_main_bootstraps_the_default_claude_harness_then_launches( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _base_env(monkeypatch) monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-test") events: list[str] = [] agent.main( @@ -217,41 +68,52 @@ def test_main_bootstraps_into_a_container_local_config_dir_then_launches( [{"name": "s", "description": "d", "instructions": "i"}], {"advance": "COMPLETE"} ), home=tmp_path, - launch=lambda cfg: events.append(f"launch:{cfg}"), + launch=lambda harness, ctx: events.append(f"launch:{harness.name}"), on_exit=lambda: events.append("on_exit"), ) commands = tmp_path / ".claude" / "commands" assert (commands / "s.md").exists() # skills rendered... assert (commands / "advance.md").exists() # ...operations rendered... assert (tmp_path / ".claude" / "settings.json").exists() # ...turn-flip hooks written... - assert (tmp_path / ".claude" / agent.MCP_CONFIG_FILE).exists() # ...MCP server wired... - assert ( - tmp_path / ".claude" / agent.WORKFLOW_OVERVIEW_FILE - ).exists() # ...workflow map written... - import json + assert (tmp_path / ".claude" / MCP_CONFIG_FILE).exists() # ...MCP server wired... + assert (tmp_path / ".claude" / WORKFLOW_OVERVIEW_FILE).exists() # ...workflow map written... + # ...launched with the claude harness, then the container is stopped on agent exit + assert events == ["launch:claude", "on_exit"] + - trust = json.loads((tmp_path / ".claude" / ".claude.json").read_text()) - assert ( - trust["projects"][str(Path.cwd())]["hasTrustDialogAccepted"] is True - ) # ...trust seeded... - # ...launched with the container-local config dir, then the container is stopped on agent exit - assert events == [f"launch:{tmp_path / '.claude'}", "on_exit"] +def test_main_passes_the_launch_context_through( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _base_env(monkeypatch) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-test") + monkeypatch.setenv("PANOPTICON_INITIAL_PROMPT", "review your plan") + monkeypatch.setenv("PANOPTICON_TASK_TURN", "agent") + monkeypatch.setenv("PANOPTICON_STARTING_MODEL", "opus") + seen: list[LaunchContext] = [] + agent.main( + client_factory=lambda url: _FakeClient(), # type: ignore[arg-type,return-value] + home=tmp_path, + launch=lambda harness, ctx: seen.append(ctx), + on_exit=lambda: None, + ) + (ctx,) = seen + assert ctx.initial_prompt == "review your plan" + assert ctx.turn == "agent" + assert ctx.starting_model == "opus" + assert ctx.home == tmp_path def test_main_fails_fast_when_no_auth_token_is_set( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("PANOPTICON_SERVICE_URL", "http://svc") - monkeypatch.setenv("PANOPTICON_TASK_ID", "t1") + _base_env(monkeypatch) monkeypatch.setenv("PANOPTICON_RUNNER_ID", "runner-1") - monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) - monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) launched: list[str] = [] - fake = _FakeClient([]) + fake = _FakeClient() agent.main( client_factory=lambda url: fake, # type: ignore[arg-type,return-value] home=tmp_path, - launch=lambda cfg: launched.append("launched"), + launch=lambda harness, ctx: launched.append("launched"), on_exit=lambda: launched.append("on_exit"), ) assert launched == [] # launch must not be called @@ -265,15 +127,13 @@ def test_main_fails_fast_when_no_auth_token_is_set( def test_main_proceeds_when_anthropic_api_key_is_set( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("PANOPTICON_SERVICE_URL", "http://svc") - monkeypatch.setenv("PANOPTICON_TASK_ID", "t1") + _base_env(monkeypatch) monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") - monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) launched: list[str] = [] agent.main( - client_factory=lambda url: _FakeClient([]), # type: ignore[arg-type,return-value] + client_factory=lambda url: _FakeClient(), # type: ignore[arg-type,return-value] home=tmp_path, - launch=lambda cfg: launched.append("launched"), + launch=lambda harness, ctx: launched.append("launched"), on_exit=lambda: launched.append("on_exit"), ) assert "launched" in launched # ANTHROPIC_API_KEY alone is sufficient @@ -282,18 +142,46 @@ def test_main_proceeds_when_anthropic_api_key_is_set( def test_main_returns_early_without_lifecycle_call_when_runner_id_absent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("PANOPTICON_SERVICE_URL", "http://svc") - monkeypatch.setenv("PANOPTICON_TASK_ID", "t1") + _base_env(monkeypatch) monkeypatch.delenv("PANOPTICON_RUNNER_ID", raising=False) - monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) - monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) launched: list[str] = [] - fake = _FakeClient([]) + fake = _FakeClient() agent.main( client_factory=lambda url: fake, # type: ignore[arg-type,return-value] home=tmp_path, - launch=lambda cfg: launched.append("launched"), + launch=lambda harness, ctx: launched.append("launched"), on_exit=lambda: launched.append("on_exit"), ) assert launched == [] # still returns early without launching assert fake.lifecycle_calls == [] # no lifecycle call when runner_id absent + + +def test_run_agent_merges_the_harness_env(monkeypatch: pytest.MonkeyPatch) -> None: + # The launch runs the harness argv with the harness env layered over the container's. + recorded: dict[str, object] = {} + + class _FakeHarness(Harness): + name = "fake" + config_dirname = ".fake" + + def missing_auth(self, environ: object, *, home: Path) -> str | None: + return None + + def bootstrap(self, ctx: object) -> None: + pass + + def argv(self, ctx: LaunchContext) -> list[str]: + return ["fake-cli", "--go"] + + def env(self, ctx: LaunchContext) -> dict[str, str]: + return {"FAKE_HOME": "/f"} + + def fake_run(argv: list[str], env: dict[str, str] | None = None) -> None: + recorded["argv"] = argv + recorded["env"] = env + + monkeypatch.setattr(agent.subprocess, "run", fake_run) + agent._run_agent(_FakeHarness(), LaunchContext(home=Path("/h"), cwd=Path("/w"))) + assert recorded["argv"] == ["fake-cli", "--go"] + env = recorded["env"] + assert isinstance(env, dict) and env["FAKE_HOME"] == "/f" diff --git a/tests/container/test_hooks.py b/tests/container/test_hooks.py index ae345f31..acc9c1bc 100644 --- a/tests/container/test_hooks.py +++ b/tests/container/test_hooks.py @@ -9,7 +9,7 @@ import pytest from panopticon.container import hook -from panopticon.container.hooks import settings, write_settings +from panopticon.harnesses.claude import settings, write_settings def test_settings_wire_stop_to_user_and_prompt_to_agent() -> None: diff --git a/tests/harnesses/test_claude.py b/tests/harnesses/test_claude.py new file mode 100644 index 00000000..c45df05f --- /dev/null +++ b/tests/harnesses/test_claude.py @@ -0,0 +1,189 @@ +"""The claude harness: argv (first-run vs resume), config rendering, trust seeds, bootstrap. + +These are the golden expectations carried over verbatim from the pre-harness agent launcher +(Slice 6) — the M3 seam extraction must not change what claude is launched with or what lands +in its config dir. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from panopticon.core.models import Skill +from panopticon.harnesses import INTERRUPT_PROMPT, BootstrapContext, LaunchContext +from panopticon.harnesses.claude import ( + MCP_CONFIG_FILE, + WORKFLOW_OVERVIEW_FILE, + ClaudeHarness, + trust_workspace, + write_mcp_config, + write_workflow_overview, +) + +HARNESS = ClaudeHarness() + + +def _ctx(home: Path, **kwargs: str | None) -> LaunchContext: + return LaunchContext(home=home, cwd=Path("/work/repo"), **kwargs) # type: ignore[arg-type] + + +def _seed_session(home: Path) -> None: + project = ( + home / ".claude" / "projects" / "-work-repo" + ) # claude's /projects/ + project.mkdir(parents=True) + (project / "session.jsonl").write_text("{}") + + +def test_argv_starts_fresh_without_a_session(tmp_path: Path) -> None: + # Unattended container, per-task clone → skip permission prompts (no operator to answer them). + assert HARNESS.argv(_ctx(tmp_path)) == ["claude", "--dangerously-skip-permissions"] + + +def test_argv_continues_an_existing_session(tmp_path: Path) -> None: + _seed_session(tmp_path) + assert HARNESS.argv(_ctx(tmp_path)) == [ + "claude", + "--dangerously-skip-permissions", + "--continue", + ] + + +def test_argv_appends_initial_prompt_on_first_session(tmp_path: Path) -> None: + argv = HARNESS.argv(_ctx(tmp_path, initial_prompt="review your plan")) + assert argv == ["claude", "--dangerously-skip-permissions", "review your plan"] + + +def test_argv_omits_initial_prompt_when_continuing_a_session(tmp_path: Path) -> None: + _seed_session(tmp_path) + argv = HARNESS.argv(_ctx(tmp_path, initial_prompt="review your plan")) + assert "--continue" in argv + assert "review your plan" not in argv + + +def test_argv_appends_interrupt_prompt_on_respawn_for_agent_turn(tmp_path: Path) -> None: + _seed_session(tmp_path) + argv = HARNESS.argv(_ctx(tmp_path, turn="agent")) + assert argv == [ + "claude", + "--dangerously-skip-permissions", + "--continue", + INTERRUPT_PROMPT, + ] + + +def test_argv_omits_interrupt_prompt_on_respawn_for_user_turn(tmp_path: Path) -> None: + _seed_session(tmp_path) + argv = HARNESS.argv(_ctx(tmp_path, turn="user")) + assert argv == ["claude", "--dangerously-skip-permissions", "--continue"] + + +def test_argv_passes_model_on_first_run(tmp_path: Path) -> None: + argv = HARNESS.argv(_ctx(tmp_path, starting_model="opus")) + assert argv == ["claude", "--dangerously-skip-permissions", "--model", "opus"] + + +def test_argv_omits_model_on_resume(tmp_path: Path) -> None: + _seed_session(tmp_path) + argv = HARNESS.argv(_ctx(tmp_path, starting_model="opus")) + assert "--model" not in argv + assert "--continue" in argv + + +def test_argv_passes_model_before_initial_prompt_on_first_run(tmp_path: Path) -> None: + argv = HARNESS.argv(_ctx(tmp_path, initial_prompt="start now", starting_model="opus")) + assert argv == ["claude", "--dangerously-skip-permissions", "--model", "opus", "start now"] + + +def test_write_mcp_config_points_claude_at_the_task_service_mcp(tmp_path: Path) -> None: + path = write_mcp_config(tmp_path, "http://host.docker.internal:8000") + assert path == tmp_path / MCP_CONFIG_FILE + cfg = json.loads(path.read_text()) + server = cfg["mcpServers"]["panopticon"] + assert server == {"type": "http", "url": "http://host.docker.internal:8000/mcp"} + + +def test_argv_adds_strict_mcp_config_when_present(tmp_path: Path) -> None: + write_mcp_config(tmp_path / ".claude", "http://svc:8000") + argv = HARNESS.argv(_ctx(tmp_path)) + assert argv == [ + "claude", + "--dangerously-skip-permissions", + "--mcp-config", + str(tmp_path / ".claude" / MCP_CONFIG_FILE), + "--strict-mcp-config", + ] + + +def test_write_workflow_overview_writes_the_map_else_skips(tmp_path: Path) -> None: + path = write_workflow_overview(tmp_path, "# github-peer-reviewed\nphases…") + assert ( + path == tmp_path / WORKFLOW_OVERVIEW_FILE + and path.read_text() == "# github-peer-reviewed\nphases…" + ) + assert write_workflow_overview(tmp_path / "empty", " ") is None # no overview → skipped + + +def test_argv_appends_the_workflow_overview_to_the_system_prompt(tmp_path: Path) -> None: + write_workflow_overview(tmp_path / ".claude", "# the workflow map") + argv = HARNESS.argv(_ctx(tmp_path)) + i = argv.index("--append-system-prompt") + assert argv[i + 1] == "# the workflow map" # the map's contents go inline + + +def test_trust_workspace_seeds_acceptance_for_a_fresh_config(tmp_path: Path) -> None: + config_dir = tmp_path / ".claude" + trust_workspace(config_dir, Path("/workspace")) + data = json.loads((config_dir / ".claude.json").read_text()) + assert data["projects"]["/workspace"]["hasTrustDialogAccepted"] is True + assert data["hasCompletedOnboarding"] is True + assert data["hasAcknowledgedCostThreshold"] is True # suppresses the API-key cost dialog + + +def test_trust_workspace_merges_and_is_idempotent(tmp_path: Path) -> None: + config_dir = tmp_path / ".claude" + config_dir.mkdir() + # claude already wrote config (incl. an existing project) — we must not clobber it. + (config_dir / ".claude.json").write_text( + json.dumps({"userID": "u", "projects": {"/other": {"history": []}}}) + ) + trust_workspace(config_dir, Path("/workspace")) + trust_workspace(config_dir, Path("/workspace")) # idempotent + data = json.loads((config_dir / ".claude.json").read_text()) + assert data["userID"] == "u" # preserved + assert data["projects"]["/other"] == {"history": []} # preserved + assert data["projects"]["/workspace"]["hasTrustDialogAccepted"] is True + + +def test_missing_auth_names_the_token_and_accepts_either_var(tmp_path: Path) -> None: + assert HARNESS.missing_auth({}, home=tmp_path) is not None + assert "CLAUDE_CODE_OAUTH_TOKEN" in (HARNESS.missing_auth({}, home=tmp_path) or "") + assert HARNESS.missing_auth({"CLAUDE_CODE_OAUTH_TOKEN": "t"}, home=tmp_path) is None + assert HARNESS.missing_auth({"ANTHROPIC_API_KEY": "k"}, home=tmp_path) is None + + +def test_bootstrap_renders_the_full_claude_surface(tmp_path: Path) -> None: + HARNESS.bootstrap( + BootstrapContext( + home=tmp_path, + cwd=Path("/workspace"), + service_url="http://svc:8000", + task_id="t1", + skills=[Skill(name="s", description="d", instructions="i")], + operations={"advance": "COMPLETE"}, + overview="# map", + ) + ) + commands = tmp_path / ".claude" / "commands" + assert (commands / "s.md").exists() # skills rendered... + assert (commands / "advance.md").exists() # ...operations rendered... + assert (tmp_path / ".claude" / "settings.json").exists() # ...turn-flip hooks written... + assert (tmp_path / ".claude" / MCP_CONFIG_FILE).exists() # ...MCP server wired... + assert (tmp_path / ".claude" / WORKFLOW_OVERVIEW_FILE).exists() # ...workflow map written... + trust = json.loads((tmp_path / ".claude" / ".claude.json").read_text()) + assert trust["projects"]["/workspace"]["hasTrustDialogAccepted"] is True # ...trust seeded + + +def test_env_points_claude_at_the_per_task_config_dir(tmp_path: Path) -> None: + assert HARNESS.env(_ctx(tmp_path)) == {"CLAUDE_CONFIG_DIR": str(tmp_path / ".claude")} diff --git a/tests/container/test_skills.py b/tests/harnesses/test_claude_skills.py similarity index 95% rename from tests/container/test_skills.py rename to tests/harnesses/test_claude_skills.py index 520bf70d..0f853c78 100644 --- a/tests/container/test_skills.py +++ b/tests/harnesses/test_claude_skills.py @@ -4,8 +4,8 @@ from pathlib import Path -from panopticon.container.skills import render_command, render_operation, write_commands from panopticon.core.models import Skill +from panopticon.harnesses.claude import render_command, render_operation, write_commands def test_render_command_is_frontmatter_plus_procedure_with_task_id() -> None: diff --git a/tests/container/test_config.py b/tests/harnesses/test_config.py similarity index 95% rename from tests/container/test_config.py rename to tests/harnesses/test_config.py index 15e82fed..a0f74689 100644 --- a/tests/container/test_config.py +++ b/tests/harnesses/test_config.py @@ -7,7 +7,7 @@ import pytest -from panopticon.container.config import update_json_config +from panopticon.harnesses.config import update_json_config def test_update_json_config_starts_empty_when_absent(tmp_path: Path) -> None: diff --git a/tests/harnesses/test_registry.py b/tests/harnesses/test_registry.py new file mode 100644 index 00000000..4e8da3c8 --- /dev/null +++ b/tests/harnesses/test_registry.py @@ -0,0 +1,40 @@ +"""The harness registry: name → adapter lookup, the claude default, unknown-name rejection. + +Chunk 1 registers claude only; codex arrives in a later chunk and extends these expectations.""" + +from __future__ import annotations + +import pytest + +from panopticon.harnesses import DEFAULT_HARNESS, HARNESSES, get_harness +from panopticon.harnesses.claude import ClaudeHarness + + +def test_registry_holds_claude() -> None: + assert set(HARNESSES) == {"claude"} + assert isinstance(HARNESSES["claude"], ClaudeHarness) + + +def test_get_harness_defaults_to_claude() -> None: + # None = a task recorded before harnesses existed (or one that never chose): the default + # surface panopticon launched with. + assert get_harness(None) is HARNESSES[DEFAULT_HARNESS] + assert get_harness(None).name == "claude" + + +def test_get_harness_by_name() -> None: + assert get_harness("claude").name == "claude" + + +def test_get_harness_rejects_an_unknown_name_listing_the_known_ones() -> None: + with pytest.raises(KeyError) as excinfo: + get_harness("cursor") + assert "cursor" in str(excinfo.value) and "claude" in str(excinfo.value) + + +def test_config_dirnames_are_distinct_dotdirs() -> None: + # Each harness gets its own config-volume mountpoint under the container home; a collision + # would make two harnesses share (and clobber) per-task state. + dirnames = [h.config_dirname for h in HARNESSES.values()] + assert len(set(dirnames)) == len(dirnames) + assert all(d.startswith(".") and "/" not in d for d in dirnames)