diff --git a/connectors/claude/.mcp.json b/connectors/claude/.mcp.json index 5fb0f887..ce7e5af1 100644 --- a/connectors/claude/.mcp.json +++ b/connectors/claude/.mcp.json @@ -1,7 +1,7 @@ { "mcpServers": { "mupot": { - "type": "sse", + "type": "http", "url": "https://YOUR-POT.example.workers.dev/mcp", "headers": { "Authorization": "Bearer " diff --git a/connectors/claude/README.md b/connectors/claude/README.md index 05d51ae5..946f7472 100644 --- a/connectors/claude/README.md +++ b/connectors/claude/README.md @@ -5,12 +5,19 @@ pot. It connects over MCP — the same seam an agent uses — carrying nothing b scoped **member token**. Identity and permissions are resolved by the pot from that token, never from anything Claude says about itself. +For the topology-A headless driver (BYOA slice 3), see +[`scripts/claude-code-worker.py`](../../scripts/claude-code-worker.py) — `claude -p` +with `--output-format stream-json`, remote MCP via `.mcp.json` (`type: "http"`, +`url`, `headers.Authorization`), land-at-review via `runtime-adapter/v1`. Starts +from the [`packs/claude-code/flock-agent`](../../packs/claude-code/flock-agent/) pack. + ## Two ways to connect -### A. Raw MCP config (`.mcp.json`) +### A. Raw MCP config (`.mcp.json`) — preferred for Claude Code Use [`.mcp.json`](./.mcp.json) as a template for Claude Code (project or user -scope) or Claude Desktop. +scope). **HTTP only** for the BYOA headless path — `type: "http"` with +`headers.Authorization`. 1. Copy `.mcp.json` into your project root (Claude Code) or merge it into your Claude Desktop config. @@ -30,7 +37,7 @@ curl https://YOUR-POT.example.workers.dev/mcp/tools You should see the tool surface: `task_create`, `remember`, `recall`, `wake_agent`, `squad_message`, `status`. -### B. The `/mupot` skill (recommended for Claude Code) +### B. The `/mupot` skill (recommended for interactive Claude Code) The [`skills/mupot/`](./skills/mupot/) plugin wraps the three everyday actions — **task**, **status**, **recall** — as `/mupot` commands so you don't hand-write @@ -50,6 +57,21 @@ MCP calls. Install: 3. In Claude Code, run `/mupot task ...`, `/mupot status`, or `/mupot recall ...`. See [`skills/mupot/SKILL.md`](./skills/mupot/SKILL.md) for the full command set. +## Headless driver (topology A) + +```bash +# Plan mint + attach without live Claude creds: +MINT_ATTACH=1 DRY_RUN=1 python3 scripts/claude-code-worker.py + +# One-shot poll loop (token at ~/.fleet/agents/claude-code-member.token): +DRY_RUN=1 python3 scripts/claude-code-worker.py +python3 scripts/claude-code-worker.py +``` + +The driver writes a worktree `.mcp.json` (`type: "http"` + Bearer header), runs +`claude -p --output-format stream-json`, verifies commits + `tsc`, opens the PR, +and lands the task at `review`. It never merges, deploys, or self-verdicts. + ## What Claude can do (gated by your capability) Everything Claude does goes through the member token's capabilities. With a token diff --git a/connectors/codex/README.md b/connectors/codex/README.md index 36907a87..6442e1a4 100644 --- a/connectors/codex/README.md +++ b/connectors/codex/README.md @@ -6,41 +6,61 @@ as Claude — `POST /mcp` with a member token — and gets the same tool surface gated by the same capability RBAC. Identity is derived by the pot from the token, never from anything Codex says about itself. +For the topology-A headless driver (BYOA slice 2), see +[`scripts/codex-worker.py`](../../scripts/codex-worker.py) — `codex exec` +with `--sandbox` + `--json`, land-at-review via `runtime-adapter/v1`. + ## Connect -Codex reads MCP servers from its config (`~/.codex/config.toml`, or the JSON form -some builds use). Add a `mupot` server pointing at your pot's `/mcp` endpoint with -your member token. +Codex reads MCP servers from `~/.codex/config.toml`. Add a `mupot` server +pointing at your pot's `/mcp` endpoint. **Streamable-HTTP only** — Codex does +**not** support SSE for remote MCP. -### TOML form (`~/.codex/config.toml`) +### TOML form (`~/.codex/config.toml`) — preferred See [`config.toml`](./config.toml): ```toml [mcp_servers.mupot] -type = "sse" url = "https://YOUR-POT.example.workers.dev/mcp" - -[mcp_servers.mupot.headers] -Authorization = "Bearer " +bearer_token_env_var = "MUPOT_MCP_TOKEN" +# then: export MUPOT_MCP_TOKEN= ``` -### JSON form (Codex builds that share Claude's `.mcp.json` shape) +Do **not** set `type = "sse"`. The token comes from an env var so the raw value +never lands in the config file. -See [`mcp.json`](./mcp.json) — identical structure to the Claude connector's -`.mcp.json`. +### JSON form (some Codex builds that share Claude's `.mcp.json` shape) + +See [`mcp.json`](./mcp.json) — use `type: "http"`, never `"sse"`. Prefer the +TOML + `bearer_token_env_var` form above for Codex CLI. ## Fill in 1. Replace `YOUR-POT.example.workers.dev` with **your** pot's host. -2. Replace `` with the raw member token your pot minted - (`channel: workspace`). See the top-level [connectors README](../README.md) for - how to mint one. **Never commit the filled-in file.** +2. Export `MUPOT_MCP_TOKEN` (or your chosen env var) to the raw member token your + pot minted (`channel: workspace`). See the top-level + [connectors README](../README.md) for how to mint one. **Never commit the + token.** + +Verify the tool surface (no token needed for discovery; token needed for calls): -Verify the tool surface (no token needed): +```bash +curl -sS https://YOUR-POT.example.workers.dev/mcp \ + -H "Authorization: Bearer $MUPOT_MCP_TOKEN" \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +## Headless driver (topology A) ```bash -curl https://YOUR-POT.example.workers.dev/mcp/tools +# Plan mint + attach without live Codex creds: +MINT_ATTACH=1 DRY_RUN=1 python3 scripts/codex-worker.py + +# One-shot poll loop (token at ~/.fleet/agents/codex-member.token): +DRY_RUN=1 python3 scripts/codex-worker.py +python3 scripts/codex-worker.py ``` ## Capability @@ -48,4 +68,5 @@ curl https://YOUR-POT.example.workers.dev/mcp/tools Codex acts within the member token's capabilities — same as Claude. `member` on a squad unlocks `task_create` / `squad_message`; `lead`+ unlocks `wake_agent`; `remember` / `recall` / `status` are self-scoped. Anything above the grant returns -`403 forbidden`. Codex cannot escalate. +`403 forbidden`. Codex cannot escalate. The driver lands work at `review` and +never merges, deploys, or self-verdicts. diff --git a/connectors/codex/config.toml b/connectors/codex/config.toml index 31ae5fb6..22a7ef7f 100644 --- a/connectors/codex/config.toml +++ b/connectors/codex/config.toml @@ -1,11 +1,13 @@ # Codex ── mupot MCP server (EXAMPLE) -# Merge into ~/.codex/config.toml. Replace the host with your pot, and the token -# with the raw member token your pot minted (channel: workspace). Never commit the -# filled-in file — this placeholder version is the only one that belongs in git. +# Merge into ~/.codex/config.toml. Replace the host with your pot. +# Set the env var named below to the raw member token your pot minted +# (channel: workspace). Never commit the filled-in token — this placeholder +# version is the only one that belongs in git. +# +# Streamable-HTTP ONLY. Do NOT set type="sse" / transport=sse — Codex does not +# support SSE for remote MCP (see docs/connect-mcp-client.md). [mcp_servers.mupot] -type = "sse" url = "https://YOUR-POT.example.workers.dev/mcp" - -[mcp_servers.mupot.headers] -Authorization = "Bearer " +bearer_token_env_var = "MUPOT_MCP_TOKEN" +# then: export MUPOT_MCP_TOKEN= (one line, no quotes/newline) diff --git a/connectors/codex/mcp.json b/connectors/codex/mcp.json index 5fb0f887..ce7e5af1 100644 --- a/connectors/codex/mcp.json +++ b/connectors/codex/mcp.json @@ -1,7 +1,7 @@ { "mcpServers": { "mupot": { - "type": "sse", + "type": "http", "url": "https://YOUR-POT.example.workers.dev/mcp", "headers": { "Authorization": "Bearer " diff --git a/docs/runtime-adapter-contract.md b/docs/runtime-adapter-contract.md index f179ec83..8df28abd 100644 --- a/docs/runtime-adapter-contract.md +++ b/docs/runtime-adapter-contract.md @@ -30,7 +30,8 @@ Agent identity and runtime binding are separate concepts. - `agent_id` names the durable Mupot agent. - `runtime` names the carrying process type, such as `codex`, - `claude-code`, `hermes`, `hermes-cron`, `systemd-user`, `tmux`, or `python`. + `claude-code`, `cursor`, `hermes`, `hermes-cron`, `systemd-user`, `tmux`, + or `python`. - `lifecycle` describes the expected host model: `on_demand` or `always_on`. - `member_id` is derived from the bearer token or registered agent key. - `tenant` is always `env.TENANT_SLUG`; client-supplied tenant fields are not diff --git a/package.json b/package.json index 6b76e3f5..a6f738f9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "seed:local:test": "wrangler d1 execute mupot-local-test --local --config wrangler-local-test.toml --file scripts/local-test-seed.sql", "smoke:local": "node scripts/local-browser-smoke.mjs", "conformance:runtime:local": "node scripts/local-runtime-conformance.mjs", + "conformance:runtime:drivers": "node scripts/runtime-adapter-driver-conformance.mjs", "receipt:host": "node fleet-runtime/host-receipt.mjs", "receipt:runtime": "node fleet-runtime/runtime-receipt.mjs", "receipt:control": "node fleet-runtime/control-receipt.mjs", diff --git a/packs/claude-code/flock-agent/.mcp.json.template b/packs/claude-code/flock-agent/.mcp.json.template index 8725e7af..eaf4b17a 100644 --- a/packs/claude-code/flock-agent/.mcp.json.template +++ b/packs/claude-code/flock-agent/.mcp.json.template @@ -1,12 +1,12 @@ { - "//": "Flock agent pack — Claude Code. Connects this agent to a mupot flock over the SOS bus.", - "//token": "Replace with the project-scoped, agent-bound token your operator minted (project=, agent=). NEVER commit this file with a real token — it is gitignored.", + "//": "Flock agent pack — Claude Code (BYOA topology A). Connects this agent to mupot over remote HTTP MCP.", + "//token": "Replace with the project-scoped, agent-bound token your operator minted. NEVER commit this file with a real token — it is gitignored.", "mcpServers": { - "mumega-bus": { - "type": "sse", - "url": "https://mcp.mumega.com/sse", + "mupot": { + "type": "http", + "url": "https://YOUR-POT.example.workers.dev/mcp", "headers": { - "Authorization": "Bearer " + "Authorization": "Bearer " } } } diff --git a/packs/claude-code/flock-agent/README.md b/packs/claude-code/flock-agent/README.md index 44f646d0..7a1c03b5 100644 --- a/packs/claude-code/flock-agent/README.md +++ b/packs/claude-code/flock-agent/README.md @@ -4,18 +4,24 @@ Make a Claude Code agent a live member of a mupot flock: it appears in the pot's `/fleet` when in, ages out when gone, and works the tenant's task queue. Reference implementation of the [harness pack contract](../../../docs/flock-harness-pack-contract.md). +For **topology-A headless dispatch** (BYOA slice 3), use +[`scripts/claude-code-worker.py`](../../../scripts/claude-code-worker.py) — it +loads this pack's `.mcp.json` shape (`type: "http"`, `url`, +`headers.Authorization`), runs `claude -p --output-format stream-json`, and lands +work at `review` via `runtime-adapter/v1`. + ## Onboard (5 steps) -1. **Get a scoped token.** Ask your operator to mint a bus token bound to your agent - name and scoped to the pot's project: `project=`, `agent=`, - read + check-in (outbound work is gated, not granted to the token). - > Operators: mint via the tenant-agent provisioning path - > (`POST /api/internal/tenants//agents/activate`, internal-secret authed). +1. **Get a scoped token.** Ask your operator to mint a member token bound to your + agent and scoped to the pot (`channel: workspace`). Least-privilege; outbound + work is gated, not granted to the token. + > Operators: mint via `mint_agent_token` / the tenant-agent provisioning path. > NEVER an admin/null-scoped token — see the #44 invariant. 2. **Drop the config.** Copy `.mcp.json.template` to `.mcp.json` in the agent's - working dir and replace `` with your token. `.mcp.json` is - gitignored — never commit the token. + working dir, set your pot host, and replace `` with your + token. `.mcp.json` is gitignored — never commit the token. Shape is + `type: "http"` + `headers.Authorization` (not SSE). 3. **Add the skill.** Copy `SKILL.md` into the agent's skills (or point the agent at this directory). It tells the agent to `boot_context` → `check_in` on start, then diff --git a/packs/claude-code/flock-agent/SKILL.md b/packs/claude-code/flock-agent/SKILL.md index 7e81f5a6..dfe3097a 100644 --- a/packs/claude-code/flock-agent/SKILL.md +++ b/packs/claude-code/flock-agent/SKILL.md @@ -8,8 +8,9 @@ description: Use when this Claude Code agent should act as a member of a mupot f This makes a Claude Code agent a live member of a tenant's flock on the SOS bus. It is the reference implementation of the [harness pack contract](../../../docs/flock-harness-pack-contract.md). -The bus tools come from the `mumega-bus` MCP server (configured via `.mcp.json` — see -this pack's `.mcp.json.template`). Your identity, project scope, and permissions are +The tools come from the `mupot` MCP server (configured via `.mcp.json` — +`type: "http"`, `url`, `headers.Authorization`; see this pack's +`.mcp.json.template`). Your identity, project scope, and permissions are derived from your token — never assume them. ## On session start (join the flock) diff --git a/scripts/README.md b/scripts/README.md index 40ecb12a..0081dde6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -78,6 +78,33 @@ It writes `tmp/local-runtime-conformance/report.json` and prints the same JSON report to stdout. Failures write `tmp/local-runtime-conformance/failure-*.json` when the process can create artifacts. +### Topology-A driver conformance (offline) + +The cursor + mumcp + codex + claude-code topology-A drivers must stay on +`runtime-adapter/v1`. This smoke is offline (no live pot) and asserts contract +markers + preserved rails: + +```bash +npm run conformance:runtime:drivers +``` + +Shared client: `scripts/runtime_adapter_v1.py`. Drivers: +`scripts/cursor-worker.py` (runtime=`cursor`), `scripts/mumcp-worker.py` +(runtime=`claude-code`, WordPress draft-only), `scripts/codex-worker.py` +(runtime=`codex`, `codex exec --sandbox --json`, remote MCP via +`~/.codex/config.toml` `url` + `bearer_token_env_var` — no SSE), +`scripts/claude-code-worker.py` (runtime=`claude-code`, `claude -p` +`--output-format stream-json`, remote MCP via worktree `.mcp.json` +`type: "http"` + `headers.Authorization` — flock-agent pack shape). + +```bash +# Codex mint+attach plan (no live Codex creds required): +MINT_ATTACH=1 DRY_RUN=1 python3 scripts/codex-worker.py + +# Claude Code mint+attach plan (no live Claude creds required): +MINT_ATTACH=1 DRY_RUN=1 python3 scripts/claude-code-worker.py +``` + ## CI local evidence GitHub Actions runs the same local evidence gate with: diff --git a/scripts/claude-code-worker.py b/scripts/claude-code-worker.py new file mode 100755 index 00000000..7750ee0a --- /dev/null +++ b/scripts/claude-code-worker.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +"""Headless Claude Code CLI -> mupot loop driver (runtime-adapter/v1, BYOA slice 3). + +Turns a `claude-code` agent into a dispatchable technician that picks up mupot +tasks and returns branches for a human/Kasra gate. Conforms to runtime-adapter/v1 +(scripts/runtime_adapter_v1.py): declared runtime type `claude-code`, +server-derived identity/tenant/capability, signed attach domain `fleet-attach:v1` +(bearer attach when no host key), land-at-review contract. + +Dispatch: `claude -p ""` headless with `--output-format stream-json`. +Remote MCP via worktree `.mcp.json` (`type: "http"`, `url`, +`headers.Authorization`) — same shape as packs/claude-code/flock-agent. + +The loop is trustworthy BY CONSTRUCTION: claude never self-closes a task +(mupot's no-self-close guard) and never touches the remote — the trusted +driver does push/PR and moves the task to `review`. Claude only writes code in +an isolated worktree. + +Flow per task (assignee = claude-code agent, status = open): + 0. boot -> boot_context + attach(runtime=claude-code) + Port-1 presence + 1. claim -> task_update status=in_progress + 2. isolate -> git worktree add -b claude-code/task- main + 3. mcp -> write .mcp.json (type:http + Authorization) into the worktree + 4. dispatch -> claude -p --output-format stream-json [PROMPT] + 5. verify -> claude must have committed; run tsc (no fake-green) + 6. deliver -> driver pushes the branch + opens the PR (claude never does) + 7. report -> land_at_review (status=review, gate_owner set, PR linked) + 8. notify -> ping Kasra-core to gate; remove the worktree (keep branch/PR) + +The driver NEVER merges or deploys. Kasra-core gates the PR and verdicts the task. + +Config (env): + MUPOT_MCP default https://mupot.mumega.com/mcp + MUPOT_API_BASE default derived from MUPOT_MCP + CLAUDE_CODE_TOKEN default ~/.fleet/agents/claude-code-member.token + CLAUDE_CODE_KEY optional ~/.fleet/agents/.key for signed attach + CLAUDE_BIN default 'claude' + REPO default /home/mumega/mupot + GATE_OWNER default 'gate:kasra-core' + MODEL optional --model override + MAX_TASKS default 1 + TIMEOUT default 1800 + DRY_RUN '1' = poll + print, do nothing + MINT_ATTACH '1' = mint/attach e2e path (dry-run ok without live Claude creds) + OPERATOR_TOKEN admin token path for live mint (optional; dry-run skips) + SKIP_PERMISSIONS default '1' → --dangerously-skip-permissions (headless) + +Usage: + python3 scripts/claude-code-worker.py # one-shot, up to MAX_TASKS + DRY_RUN=1 python3 scripts/claude-code-worker.py # show what it would do + MINT_ATTACH=1 DRY_RUN=1 python3 scripts/claude-code-worker.py # mint+attach plan +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +# Allow `python3 scripts/claude-code-worker.py` to import the sibling adapter module. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from runtime_adapter_v1 import ( # noqa: E402 + CONTRACT_ID, + LAND_AT_STATUS, + SIGNED_ATTACH_DOMAIN, + AdapterConfig, + RuntimeIdentity, + api_base_from_mcp, + boot_session, + claim_in_progress, + config_from_env, + land_at_review, + mcp_call, + poll_open_tasks, + read_token, + report_blocked, +) + +RUNTIME_TYPE = "claude-code" +AGENT_TYPE = "builder" +LIFECYCLE = "on_demand" + +CLAUDE_CODE_TOKEN_PATH = Path( + os.environ.get("CLAUDE_CODE_TOKEN", str(Path.home() / ".fleet/agents/claude-code-member.token")) +) +CLAUDE_CODE_KEY_PATH = Path(os.environ.get("CLAUDE_CODE_KEY", "")) if os.environ.get("CLAUDE_CODE_KEY") else None +CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude") +REPO = Path(os.environ.get("REPO", "/home/mumega/mupot")) +MODEL = os.environ.get("MODEL", "").strip() +MAX_TASKS = int(os.environ.get("MAX_TASKS", "1")) +TIMEOUT = int(os.environ.get("TIMEOUT", "1800")) +DRY_RUN = os.environ.get("DRY_RUN", "") == "1" +MINT_ATTACH = os.environ.get("MINT_ATTACH", "") == "1" +OPERATOR_TOKEN_PATH = Path(os.environ.get("OPERATOR_TOKEN", "")) if os.environ.get("OPERATOR_TOKEN") else None +SKIP_PERMISSIONS = os.environ.get("SKIP_PERMISSIONS", "1") == "1" + +REPO_SLUG = os.environ.get("REPO_SLUG", "Mumega-com/mupot") +WORKTREE_ROOT = Path(os.environ.get("WORKTREE_ROOT", "/home/mumega/mupot-worktrees")) +MUPOT_MCP = os.environ.get("MUPOT_MCP", "https://mupot.mumega.com/mcp") + +MCP_SERVER_NAME = "mupot" +MCP_JSON_NAME = ".mcp.json" +OUTPUT_FORMAT = "stream-json" + + +def log(msg: str) -> None: + print(f"[claude-code-worker] {msg}", flush=True) + + +def git(*args: str, cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess: + env = dict(os.environ) + env.pop("GITHUB_TOKEN", None) # gh token shadow guard + return subprocess.run( + ["git", *args], cwd=str(cwd or REPO), env=env, check=check, capture_output=True, text=True + ) + + +def mcp_json_document(mcp_url: str, token: str) -> dict: + """Remote HTTP MCP for Claude Code — type:http + url + headers.Authorization.""" + return { + "mcpServers": { + MCP_SERVER_NAME: { + "type": "http", + "url": mcp_url, + "headers": { + "Authorization": f"Bearer {token}", + }, + } + } + } + + +def mcp_json_template(mcp_url: str) -> dict: + """Placeholder-shaped document for dry-run / docs (no live token).""" + return mcp_json_document(mcp_url=mcp_url, token="") + + +def ensure_worktree_mcp_json(worktree: Path, mcp_url: str, token: str, *, write: bool) -> str: + """Write `.mcp.json` into the worktree so headless `claude -p` loads mupot MCP. + + Returns the JSON text. Refuses SSE (`type: "sse"`). + """ + doc = mcp_json_document(mcp_url=mcp_url, token=token) + server = doc["mcpServers"][MCP_SERVER_NAME] + if server.get("type") == "sse": + raise RuntimeError("Claude Code BYOA adapter requires type:http, not sse") + if "Authorization" not in server.get("headers", {}): + raise RuntimeError("Claude Code .mcp.json must set headers.Authorization") + text = json.dumps(doc, indent=2) + "\n" + if not write: + return text + path = worktree / MCP_JSON_NAME + path.write_text(text) + path.chmod(0o600) + # Keep the token out of any accidental commit Claude might attempt. + gitignore = worktree / ".gitignore" + ignore_line = MCP_JSON_NAME + if gitignore.is_file(): + existing = gitignore.read_text() + if ignore_line not in existing.splitlines(): + gitignore.write_text(existing.rstrip() + "\n" + ignore_line + "\n") + else: + gitignore.write_text(ignore_line + "\n") + log(f"wrote type:http {MCP_JSON_NAME} to {path}") + return text + + +def build_brief(task: dict, worktree: Path, branch: str) -> str: + return "\n".join( + [ + f"You are the claude-code agent. Task from mupot (id {task['id']}).", + f"Work ONLY in this worktree: {worktree} (branch {branch}, already checked out).", + "", + f"TITLE: {task.get('title','')}", + f"DONE WHEN: {task.get('done_when','')}", + "", + "BRIEF:", + task.get("body", "") or "(no body — infer from title/done_when)", + "", + "RULES (hard):", + "- Make the change and COMMIT it in this worktree. Do NOT push, do NOT open a PR,", + " do NOT merge, do NOT deploy — the driver handles delivery and a human gates it.", + "- Run `npx tsc --noEmit` and the affected `npx vitest run` yourself; the change must be clean+green.", + "- Pure, minimal, behavior-correct. If blocked or the task is unsafe, commit nothing and explain why.", + "- You have the mupot MCP server for read-only context (task_list, recall, boot_context).", + f"- Do NOT commit or modify {MCP_JSON_NAME} (gitignored; driver-owned).", + ] + ) + + +def claude_run(worktree: Path, brief: str) -> subprocess.CompletedProcess: + cmd = [ + CLAUDE_BIN, + "-p", + brief, + "--output-format", + OUTPUT_FORMAT, + ] + if SKIP_PERMISSIONS: + cmd.append("--dangerously-skip-permissions") + if MODEL: + cmd += ["--model", MODEL] + log(f"dispatching {CLAUDE_BIN} -p --output-format {OUTPUT_FORMAT} (timeout {TIMEOUT}s) ...") + return subprocess.run(cmd, cwd=str(worktree), capture_output=True, text=True, timeout=TIMEOUT) + + +def verify(worktree: Path, branch: str) -> tuple[bool, str]: + """claude must have committed real work + it must compile. No fake-green.""" + commits = git("log", "main..HEAD", "--oneline", cwd=worktree, check=False).stdout.strip() + if not commits: + return False, "no commits — claude-code produced no work" + tsc = subprocess.run(["npx", "tsc", "--noEmit"], cwd=str(worktree), capture_output=True, text=True) + if tsc.returncode != 0: + return False, f"tsc errors:\n{tsc.stdout[-1500:]}{tsc.stderr[-500:]}" + return True, f"commits:\n{commits}" + + +def deliver(worktree: Path, branch: str, task: dict) -> str: + """Driver (trusted) pushes + opens the PR. claude never touches the remote.""" + git("push", "-u", "origin", branch, cwd=worktree) + env = dict(os.environ) + env.pop("GITHUB_TOKEN", None) + title = f"claude-code: {task.get('title','')[:60]}" + pr_body = ( + f"Dispatched to the `claude-code` agent headless via the mupot loop " + f"({CONTRACT_ID}, runtime={RUNTIME_TYPE}) for task `{task['id']}`.\n\n" + f"Task done-when: {task.get('done_when','')}\n\n" + "Driver verified: claude-code committed real work + `tsc --noEmit` clean. " + f"**Kasra-core gates this PR before merge** (task lands at `{LAND_AT_STATUS}`; " + "claude-code cannot self-close it)." + ) + out = subprocess.run( + ["gh", "pr", "create", "--repo", REPO_SLUG, "--base", "main", "--head", branch, + "--title", title, "--body", pr_body], + cwd=str(worktree), env=env, capture_output=True, text=True, + ) + url = (out.stdout or "").strip().splitlines()[-1] if out.stdout.strip() else "" + if not url: + raise RuntimeError(f"gh pr create failed: {out.stderr}") + return url + + +def run_task(cfg: AdapterConfig, task: dict) -> None: + tid = task["id"] + short = tid.split("-")[0] + branch = f"claude-code/task-{short}" + worktree = WORKTREE_ROOT / f"claude-code-{short}" + log(f"=== task {short}: {task.get('title','')[:60]} ===") + if DRY_RUN: + log("DRY_RUN — would claim, write .mcp.json, dispatch claude -p, verify, PR, review. Skipping.") + return + + claim_in_progress(cfg, task_id=tid) + WORKTREE_ROOT.mkdir(parents=True, exist_ok=True) + git("worktree", "add", "-b", branch, str(worktree), "main") + try: + ensure_worktree_mcp_json(worktree, cfg.mcp_url, cfg.token, write=True) + proc = claude_run(worktree, build_brief(task, worktree, branch)) + log(f"claude exit {proc.returncode}; output tail:\n{(proc.stdout or '')[-800:]}") + ok, note = verify(worktree, branch) + if not ok: + log(f"verify FAILED: {note}") + report_blocked( + cfg, + task_id=tid, + body=f"{task.get('body','')}\n\n---\nclaude-code loop BLOCKED: {note}", + ) + return + pr_url = deliver(worktree, branch, task) + land_at_review( + cfg, + task_id=tid, + body=f"{task.get('body','')}\n\n---\nclaude-code loop -> {LAND_AT_STATUS}. PR: {pr_url}\n{note}", + ) + log(f"delivered -> {LAND_AT_STATUS}. PR: {pr_url}") + _notify_kasra(task, pr_url) + finally: + git("worktree", "remove", str(worktree), "--force", cwd=REPO, check=False) + git("worktree", "prune", cwd=REPO, check=False) + + +def _notify_kasra(task: dict, pr_url: str) -> None: + """Best-effort bus ping so Kasra-core gates the PR. Non-fatal if it fails.""" + try: + subprocess.run( + ["python3", str(Path.home() / "scripts/bus-send.py"), + "kasra", f"claude-code loop: task {task['id'].split('-')[0]} in review, PR ready to gate: {pr_url}"], + capture_output=True, text=True, timeout=20, + ) + except Exception as exc: # noqa: BLE001 - notify is best-effort + log(f"notify kasra failed (non-fatal): {exc}") + + +def run_mint_attach(*, dry_run: bool) -> int: + """Mint a claude-code agent + token and attach (dry-run ok without live Claude creds). + + Live path needs OPERATOR_TOKEN (admin) for create_agent + mint_agent_token. + Dry-run prints the plan, shows the type:http .mcp.json shape, and stops before + calling the pot or `claude -p`. + """ + mcp_url = MUPOT_MCP + api_base = os.environ.get("MUPOT_API_BASE") or api_base_from_mcp(mcp_url) + slug = os.environ.get("CLAUDE_CODE_AGENT_SLUG", "claude-code") + squad = os.environ.get("CLAUDE_CODE_SQUAD", "core") + template = json.dumps(mcp_json_template(mcp_url), indent=2) + + log(f"{CONTRACT_ID} mint-attach path runtime={RUNTIME_TYPE} attach_domain={SIGNED_ATTACH_DOMAIN}") + log(f"mcp endpoint: {mcp_url}") + log(f"api base: {api_base}") + log(f".mcp.json shape (type:http + headers.Authorization):\n{template}") + log( + f"plan: create_agent {{ squad: {squad!r}, slug: {slug!r}, name: 'Claude Code', " + f"model: 'claude' }} → mint_agent_token {{ agent: {slug!r} }} → " + f"write worktree {MCP_JSON_NAME} → attach(runtime={RUNTIME_TYPE!r}) → " + f"land work at {LAND_AT_STATUS!r}" + ) + + if dry_run: + log( + "DRY_RUN mint-attach — not calling create_agent/mint_agent_token/attach; " + "no live Claude credentials required. Conformance: .mcp.json uses " + "type:http + url + headers.Authorization only." + ) + return 0 + + if OPERATOR_TOKEN_PATH is None or not OPERATOR_TOKEN_PATH.is_file(): + log("OPERATOR_TOKEN required for live mint-attach (or set DRY_RUN=1)") + return 2 + + op_cfg = AdapterConfig( + mcp_url=mcp_url, + api_base_url=api_base, + token=read_token(OPERATOR_TOKEN_PATH), + runtime=RUNTIME_TYPE, + agent_type=AGENT_TYPE, + lifecycle=LIFECYCLE, + user_agent=f"claude-code-worker/1.0 (+mupot; {CONTRACT_ID}; mint-attach)", + gate_owner=os.environ.get("GATE_OWNER", "gate:kasra-core"), + key_path=None, + ) + created = mcp_call( + op_cfg, + "create_agent", + {"squad": squad, "slug": slug, "name": "Claude Code", "model": "claude", "role": "builder"}, + ) + agent = created.get("agent") if isinstance(created.get("agent"), dict) else {} + agent_id = agent.get("id") + log(f"create_agent ok id={agent_id!r} slug={agent.get('slug')!r}") + minted = mcp_call(op_cfg, "mint_agent_token", {"agent": slug, "label": f"{slug}-member"}) + token_obj = minted.get("token") if isinstance(minted.get("token"), dict) else {} + raw = token_obj.get("raw") + if not isinstance(raw, str) or not raw: + raise RuntimeError(f"mint_agent_token returned no raw token: {minted}") + CLAUDE_CODE_TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True) + CLAUDE_CODE_TOKEN_PATH.write_text(raw + "\n") + CLAUDE_CODE_TOKEN_PATH.chmod(0o600) + log(f"wrote agent token to {CLAUDE_CODE_TOKEN_PATH} (show-once raw stored locally)") + + agent_cfg = config_from_env( + token_path=CLAUDE_CODE_TOKEN_PATH, + runtime=RUNTIME_TYPE, + agent_type=AGENT_TYPE, + user_agent=f"claude-code-worker/1.0 (+mupot; {CONTRACT_ID})", + lifecycle=LIFECYCLE, + key_path=CLAUDE_CODE_KEY_PATH, + ) + identity = boot_session( + agent_cfg, + presence_adapter="claude-code", + presence_capabilities=["build"], + log=log, + ) + log(f"mint-attach complete agent={identity.agent_id} tenant={identity.tenant}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Claude Code topology-A runtime-adapter/v1 driver") + parser.add_argument( + "--mint-attach", + action="store_true", + help="Mint claude-code agent + token and attach (respects DRY_RUN)", + ) + args = parser.parse_args(argv) + mint_attach = bool(args.mint_attach) or MINT_ATTACH + + if mint_attach: + return run_mint_attach(dry_run=DRY_RUN) + + if not CLAUDE_CODE_TOKEN_PATH.exists(): + log(f"no claude-code token at {CLAUDE_CODE_TOKEN_PATH} (or run MINT_ATTACH=1 DRY_RUN=1 first)") + return 2 + + cfg = config_from_env( + token_path=CLAUDE_CODE_TOKEN_PATH, + runtime=RUNTIME_TYPE, + agent_type=AGENT_TYPE, + user_agent=f"claude-code-worker/1.0 (+mupot; {CONTRACT_ID})", + lifecycle=LIFECYCLE, + key_path=CLAUDE_CODE_KEY_PATH, + ) + log(f"{CONTRACT_ID} attach_domain={SIGNED_ATTACH_DOMAIN} land_at={LAND_AT_STATUS}") + if DRY_RUN: + template = json.dumps(mcp_json_template(cfg.mcp_url), indent=2) + log( + f"DRY_RUN — would boot_session(runtime={RUNTIME_TYPE}), poll own-assignee open tasks, " + f"write {MCP_JSON_NAME}, claude -p --output-format {OUTPUT_FORMAT}, verify, PR, land_at_review" + ) + log(f".mcp.json shape:\n{template}") + try: + identity: RuntimeIdentity = boot_session( + cfg, + presence_adapter="claude-code", + presence_capabilities=["build"], + log=log, + ) + tasks = poll_open_tasks(cfg, identity, limit=MAX_TASKS) + log(f"{len(tasks)} open task(s) assigned to claude-code ({identity.agent_id})") + for task in tasks: + run_task(cfg, task) + except Exception as exc: # noqa: BLE001 - dry-run may lack live pot access + log(f"DRY_RUN boot/poll skipped ({exc})") + return 0 + + identity = boot_session( + cfg, + presence_adapter="claude-code", + presence_capabilities=["build"], + log=log, + ) + tasks = poll_open_tasks(cfg, identity, limit=MAX_TASKS) + log(f"{len(tasks)} open task(s) assigned to claude-code ({identity.agent_id})") + for task in tasks: + try: + run_task(cfg, task) + except Exception as exc: # noqa: BLE001 - one task's failure must not kill the loop + log(f"task {task.get('id')} errored: {exc}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/codex-worker.py b/scripts/codex-worker.py new file mode 100755 index 00000000..6fedc7f3 --- /dev/null +++ b/scripts/codex-worker.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +"""Headless Codex CLI -> mupot loop driver (runtime-adapter/v1, BYOA slice 2). + +Turns a `codex` agent into a dispatchable technician that picks up mupot tasks +and returns branches for a human/Kasra gate. Conforms to runtime-adapter/v1 +(scripts/runtime_adapter_v1.py): declared runtime type `codex`, server-derived +identity/tenant/capability, signed attach domain `fleet-attach:v1` (bearer +attach when no host key), land-at-review contract. + +Dispatch: `codex exec [PROMPT]` headless with `--sandbox` + `--json`. Remote +MCP is streamable-HTTP only via `~/.codex/config.toml` `[mcp_servers.mupot]` +(`url` + `bearer_token_env_var` — NO SSE). + +The loop is trustworthy BY CONSTRUCTION: codex never self-closes a task +(mupot's no-self-close guard) and never touches the remote — the trusted +driver does push/PR and moves the task to `review`. Codex only writes code in +an isolated worktree. + +Flow per task (assignee = codex agent, status = open): + 0. boot -> boot_context + attach(runtime=codex) + Port-1 presence + 1. claim -> task_update status=in_progress + 2. isolate -> git worktree add -b codex/task- main + 3. dispatch -> codex exec --sandbox --json -C [PROMPT] + 4. verify -> codex must have committed; run tsc (no fake-green) + 5. deliver -> driver pushes the branch + opens the PR (codex never does) + 6. report -> land_at_review (status=review, gate_owner set, PR linked) + 7. notify -> ping Kasra-core to gate; remove the worktree (keep branch/PR) + +The driver NEVER merges or deploys. Kasra-core gates the PR and verdicts the task. + +Config (env): + MUPOT_MCP default https://mupot.mumega.com/mcp + MUPOT_API_BASE default derived from MUPOT_MCP + CODEX_TOKEN default ~/.fleet/agents/codex-member.token + CODEX_KEY optional ~/.fleet/agents/.key for signed attach + CODEX_BIN default 'codex' + CODEX_HOME default ~/.codex (config.toml location) + CODEX_MCP_ENV_VAR default MUPOT_MCP_TOKEN (bearer_token_env_var name) + REPO default /home/mumega/mupot + GATE_OWNER default 'gate:kasra-core' + MODEL optional --model override + SANDBOX default workspace-write (read-only|workspace-write|danger-full-access) + MAX_TASKS default 1 + TIMEOUT default 1800 + DRY_RUN '1' = poll + print, do nothing + MINT_ATTACH '1' = mint/attach e2e path (dry-run ok without live Codex creds) + OPERATOR_TOKEN admin token path for live mint (optional; dry-run skips) + +Usage: + python3 scripts/codex-worker.py # one-shot, up to MAX_TASKS + DRY_RUN=1 python3 scripts/codex-worker.py # show what it would do + MINT_ATTACH=1 DRY_RUN=1 python3 scripts/codex-worker.py # mint+attach plan +""" +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +# Allow `python3 scripts/codex-worker.py` to import the sibling adapter module. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from runtime_adapter_v1 import ( # noqa: E402 + CONTRACT_ID, + LAND_AT_STATUS, + SIGNED_ATTACH_DOMAIN, + AdapterConfig, + RuntimeIdentity, + api_base_from_mcp, + boot_session, + claim_in_progress, + config_from_env, + land_at_review, + mcp_call, + poll_open_tasks, + read_token, + report_blocked, +) + +RUNTIME_TYPE = "codex" +AGENT_TYPE = "builder" +LIFECYCLE = "on_demand" + +CODEX_TOKEN_PATH = Path(os.environ.get("CODEX_TOKEN", str(Path.home() / ".fleet/agents/codex-member.token"))) +CODEX_KEY_PATH = Path(os.environ.get("CODEX_KEY", "")) if os.environ.get("CODEX_KEY") else None +CODEX_BIN = os.environ.get("CODEX_BIN", "codex") +CODEX_HOME = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))) +CODEX_MCP_ENV_VAR = os.environ.get("CODEX_MCP_ENV_VAR", "MUPOT_MCP_TOKEN") +CODEX_CONFIG = CODEX_HOME / "config.toml" +REPO = Path(os.environ.get("REPO", "/home/mumega/mupot")) +MODEL = os.environ.get("MODEL", "").strip() +MAX_TASKS = int(os.environ.get("MAX_TASKS", "1")) +TIMEOUT = int(os.environ.get("TIMEOUT", "1800")) +SANDBOX = os.environ.get("SANDBOX", "workspace-write").strip() +DRY_RUN = os.environ.get("DRY_RUN", "") == "1" +MINT_ATTACH = os.environ.get("MINT_ATTACH", "") == "1" +OPERATOR_TOKEN_PATH = Path(os.environ.get("OPERATOR_TOKEN", "")) if os.environ.get("OPERATOR_TOKEN") else None + +REPO_SLUG = os.environ.get("REPO_SLUG", "Mumega-com/mupot") +WORKTREE_ROOT = Path(os.environ.get("WORKTREE_ROOT", "/home/mumega/mupot-worktrees")) +MUPOT_MCP = os.environ.get("MUPOT_MCP", "https://mupot.mumega.com/mcp") + +VALID_SANDBOX = frozenset({"read-only", "workspace-write", "danger-full-access"}) +MCP_SERVER_NAME = "mupot" +MCP_STANZA_MARKER = f"[mcp_servers.{MCP_SERVER_NAME}]" + + +def log(msg: str) -> None: + print(f"[codex-worker] {msg}", flush=True) + + +def git(*args: str, cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess: + env = dict(os.environ) + env.pop("GITHUB_TOKEN", None) # gh token shadow guard + return subprocess.run( + ["git", *args], cwd=str(cwd or REPO), env=env, check=check, capture_output=True, text=True + ) + + +def mcp_config_stanza(mcp_url: str, env_var: str) -> str: + """Streamable-HTTP remote MCP for Codex — url + bearer_token_env_var, NO SSE.""" + return "\n".join( + [ + MCP_STANZA_MARKER, + f'url = "{mcp_url}"', + f'bearer_token_env_var = "{env_var}"', + "", + ] + ) + + +def ensure_codex_mcp_config(mcp_url: str, env_var: str, *, write: bool) -> str: + """Ensure ~/.codex/config.toml has [mcp_servers.mupot] streamable-HTTP stanza. + + Returns the stanza text. Refuses SSE (`type = "sse"` / transport=sse). + """ + stanza = mcp_config_stanza(mcp_url=mcp_url, env_var=env_var) + if not write: + return stanza + CODEX_HOME.mkdir(parents=True, exist_ok=True) + existing = CODEX_CONFIG.read_text() if CODEX_CONFIG.is_file() else "" + prior = re.search( + rf"\[mcp_servers\.{re.escape(MCP_SERVER_NAME)}\][\s\S]*?(?=\n\[|\Z)", + existing, + ) + if prior and re.search(r'type\s*=\s*"sse"|transport\s*=\s*"sse"', prior.group(0)): + raise RuntimeError( + f"{CODEX_CONFIG} has SSE mcp_servers.{MCP_SERVER_NAME} — remove it; " + "Codex supports streamable-HTTP only (url + bearer_token_env_var)" + ) + if prior and f'bearer_token_env_var = "{env_var}"' in prior.group(0) and f'url =' in prior.group(0): + log(f"mcp config already present in {CODEX_CONFIG}") + return stanza + # Drop a prior mupot stanza (any shape) then append the correct one. + cleaned = re.sub( + rf"\[mcp_servers\.{re.escape(MCP_SERVER_NAME)}\][\s\S]*?(?=\n\[|\Z)", + "", + existing, + ).rstrip() + new_text = (cleaned + "\n\n" + stanza).lstrip() + "\n" if cleaned else stanza + CODEX_CONFIG.write_text(new_text) + log(f"wrote streamable-HTTP mcp_servers.{MCP_SERVER_NAME} to {CODEX_CONFIG}") + return stanza + + +def build_brief(task: dict, worktree: Path, branch: str) -> str: + return "\n".join( + [ + f"You are the codex agent. Task from mupot (id {task['id']}).", + f"Work ONLY in this worktree: {worktree} (branch {branch}, already checked out).", + "", + f"TITLE: {task.get('title','')}", + f"DONE WHEN: {task.get('done_when','')}", + "", + "BRIEF:", + task.get("body", "") or "(no body — infer from title/done_when)", + "", + "RULES (hard):", + "- Make the change and COMMIT it in this worktree. Do NOT push, do NOT open a PR,", + " do NOT merge, do NOT deploy — the driver handles delivery and a human gates it.", + "- Run `npx tsc --noEmit` and the affected `npx vitest run` yourself; the change must be clean+green.", + "- Pure, minimal, behavior-correct. If blocked or the task is unsafe, commit nothing and explain why.", + "- You have the mupot MCP server for read-only context (task_list, recall, boot_context).", + ] + ) + + +def codex_run(worktree: Path, brief: str, token: str) -> subprocess.CompletedProcess: + if SANDBOX not in VALID_SANDBOX: + raise ValueError(f"SANDBOX must be one of {sorted(VALID_SANDBOX)}, got {SANDBOX!r}") + cmd = [ + CODEX_BIN, + "exec", + "--sandbox", + SANDBOX, + "--json", + "-C", + str(worktree), + ] + if MODEL: + cmd += ["--model", MODEL] + cmd.append(brief) + env = dict(os.environ) + env[CODEX_MCP_ENV_VAR] = token + log(f"dispatching {CODEX_BIN} exec --sandbox {SANDBOX} --json (timeout {TIMEOUT}s) ...") + return subprocess.run(cmd, cwd=str(worktree), env=env, capture_output=True, text=True, timeout=TIMEOUT) + + +def verify(worktree: Path, branch: str) -> tuple[bool, str]: + """codex must have committed real work + it must compile. No fake-green.""" + commits = git("log", "main..HEAD", "--oneline", cwd=worktree, check=False).stdout.strip() + if not commits: + return False, "no commits — codex produced no work" + tsc = subprocess.run(["npx", "tsc", "--noEmit"], cwd=str(worktree), capture_output=True, text=True) + if tsc.returncode != 0: + return False, f"tsc errors:\n{tsc.stdout[-1500:]}{tsc.stderr[-500:]}" + return True, f"commits:\n{commits}" + + +def deliver(worktree: Path, branch: str, task: dict) -> str: + """Driver (trusted) pushes + opens the PR. codex never touches the remote.""" + git("push", "-u", "origin", branch, cwd=worktree) + env = dict(os.environ) + env.pop("GITHUB_TOKEN", None) + title = f"codex: {task.get('title','')[:60]}" + pr_body = ( + f"Dispatched to the `codex` agent headless via the mupot loop " + f"({CONTRACT_ID}, runtime={RUNTIME_TYPE}) for task `{task['id']}`.\n\n" + f"Task done-when: {task.get('done_when','')}\n\n" + "Driver verified: codex committed real work + `tsc --noEmit` clean. " + f"**Kasra-core gates this PR before merge** (task lands at `{LAND_AT_STATUS}`; " + "codex cannot self-close it)." + ) + out = subprocess.run( + ["gh", "pr", "create", "--repo", REPO_SLUG, "--base", "main", "--head", branch, + "--title", title, "--body", pr_body], + cwd=str(worktree), env=env, capture_output=True, text=True, + ) + url = (out.stdout or "").strip().splitlines()[-1] if out.stdout.strip() else "" + if not url: + raise RuntimeError(f"gh pr create failed: {out.stderr}") + return url + + +def run_task(cfg: AdapterConfig, task: dict) -> None: + tid = task["id"] + short = tid.split("-")[0] + branch = f"codex/task-{short}" + worktree = WORKTREE_ROOT / f"codex-{short}" + log(f"=== task {short}: {task.get('title','')[:60]} ===") + if DRY_RUN: + log("DRY_RUN — would claim, dispatch codex exec, verify, PR, review. Skipping.") + return + + claim_in_progress(cfg, task_id=tid) + WORKTREE_ROOT.mkdir(parents=True, exist_ok=True) + git("worktree", "add", "-b", branch, str(worktree), "main") + try: + ensure_codex_mcp_config(cfg.mcp_url, CODEX_MCP_ENV_VAR, write=True) + proc = codex_run(worktree, build_brief(task, worktree, branch), cfg.token) + log(f"codex exit {proc.returncode}; output tail:\n{(proc.stdout or '')[-800:]}") + ok, note = verify(worktree, branch) + if not ok: + log(f"verify FAILED: {note}") + report_blocked( + cfg, + task_id=tid, + body=f"{task.get('body','')}\n\n---\ncodex loop BLOCKED: {note}", + ) + return + pr_url = deliver(worktree, branch, task) + land_at_review( + cfg, + task_id=tid, + body=f"{task.get('body','')}\n\n---\ncodex loop -> {LAND_AT_STATUS}. PR: {pr_url}\n{note}", + ) + log(f"delivered -> {LAND_AT_STATUS}. PR: {pr_url}") + _notify_kasra(task, pr_url) + finally: + git("worktree", "remove", str(worktree), "--force", cwd=REPO, check=False) + git("worktree", "prune", cwd=REPO, check=False) + + +def _notify_kasra(task: dict, pr_url: str) -> None: + """Best-effort bus ping so Kasra-core gates the PR. Non-fatal if it fails.""" + try: + subprocess.run( + ["python3", str(Path.home() / "scripts/bus-send.py"), + "kasra", f"codex loop: task {task['id'].split('-')[0]} in review, PR ready to gate: {pr_url}"], + capture_output=True, text=True, timeout=20, + ) + except Exception as exc: # noqa: BLE001 - notify is best-effort + log(f"notify kasra failed (non-fatal): {exc}") + + +def run_mint_attach(*, dry_run: bool) -> int: + """Mint a codex agent + token and attach (dry-run acceptable without live Codex creds). + + Live path needs OPERATOR_TOKEN (admin) for create_agent + mint_agent_token. + Dry-run prints the plan, writes/shows the streamable-HTTP config.toml stanza, + and stops before calling the pot or `codex exec`. + """ + mcp_url = MUPOT_MCP + api_base = os.environ.get("MUPOT_API_BASE") or api_base_from_mcp(mcp_url) + slug = os.environ.get("CODEX_AGENT_SLUG", "codex") + squad = os.environ.get("CODEX_SQUAD", "core") + stanza = ensure_codex_mcp_config(mcp_url, CODEX_MCP_ENV_VAR, write=not dry_run) + + log(f"{CONTRACT_ID} mint-attach path runtime={RUNTIME_TYPE} attach_domain={SIGNED_ATTACH_DOMAIN}") + log(f"mcp endpoint: {mcp_url}") + log(f"api base: {api_base}") + log(f"config.toml stanza (streamable-HTTP, no SSE):\n{stanza}") + log( + f"plan: create_agent {{ squad: {squad!r}, slug: {slug!r}, name: 'Codex', " + f"model: 'codex' }} → mint_agent_token {{ agent: {slug!r} }} → " + f"export {CODEX_MCP_ENV_VAR}= → attach(runtime={RUNTIME_TYPE!r}) → " + f"land work at {LAND_AT_STATUS!r}" + ) + + if dry_run: + log( + "DRY_RUN mint-attach — not calling create_agent/mint_agent_token/attach; " + "no live Codex credentials required. Conformance: config uses " + "url + bearer_token_env_var only." + ) + return 0 + + if OPERATOR_TOKEN_PATH is None or not OPERATOR_TOKEN_PATH.is_file(): + log("OPERATOR_TOKEN required for live mint-attach (or set DRY_RUN=1)") + return 2 + + op_cfg = AdapterConfig( + mcp_url=mcp_url, + api_base_url=api_base, + token=read_token(OPERATOR_TOKEN_PATH), + runtime=RUNTIME_TYPE, + agent_type=AGENT_TYPE, + lifecycle=LIFECYCLE, + user_agent=f"codex-worker/1.0 (+mupot; {CONTRACT_ID}; mint-attach)", + gate_owner=os.environ.get("GATE_OWNER", "gate:kasra-core"), + key_path=None, + ) + created = mcp_call( + op_cfg, + "create_agent", + {"squad": squad, "slug": slug, "name": "Codex", "model": "codex", "role": "builder"}, + ) + agent = created.get("agent") if isinstance(created.get("agent"), dict) else {} + agent_id = agent.get("id") + log(f"create_agent ok id={agent_id!r} slug={agent.get('slug')!r}") + minted = mcp_call(op_cfg, "mint_agent_token", {"agent": slug, "label": f"{slug}-member"}) + token_obj = minted.get("token") if isinstance(minted.get("token"), dict) else {} + raw = token_obj.get("raw") + if not isinstance(raw, str) or not raw: + raise RuntimeError(f"mint_agent_token returned no raw token: {minted}") + CODEX_TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True) + CODEX_TOKEN_PATH.write_text(raw + "\n") + CODEX_TOKEN_PATH.chmod(0o600) + log(f"wrote agent token to {CODEX_TOKEN_PATH} (show-once raw stored locally)") + + agent_cfg = config_from_env( + token_path=CODEX_TOKEN_PATH, + runtime=RUNTIME_TYPE, + agent_type=AGENT_TYPE, + user_agent=f"codex-worker/1.0 (+mupot; {CONTRACT_ID})", + lifecycle=LIFECYCLE, + key_path=CODEX_KEY_PATH, + ) + identity = boot_session( + agent_cfg, + presence_adapter="codex", + presence_capabilities=["build"], + log=log, + ) + log(f"mint-attach complete agent={identity.agent_id} tenant={identity.tenant}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Codex topology-A runtime-adapter/v1 driver") + parser.add_argument( + "--mint-attach", + action="store_true", + help="Mint codex agent + token and attach (respects DRY_RUN)", + ) + args = parser.parse_args(argv) + mint_attach = bool(args.mint_attach) or MINT_ATTACH + + if mint_attach: + return run_mint_attach(dry_run=DRY_RUN) + + if SANDBOX not in VALID_SANDBOX: + log(f"invalid SANDBOX={SANDBOX!r}; want one of {sorted(VALID_SANDBOX)}") + return 2 + if not CODEX_TOKEN_PATH.exists(): + log(f"no codex token at {CODEX_TOKEN_PATH} (or run MINT_ATTACH=1 DRY_RUN=1 first)") + return 2 + + cfg = config_from_env( + token_path=CODEX_TOKEN_PATH, + runtime=RUNTIME_TYPE, + agent_type=AGENT_TYPE, + user_agent=f"codex-worker/1.0 (+mupot; {CONTRACT_ID})", + lifecycle=LIFECYCLE, + key_path=CODEX_KEY_PATH, + ) + log(f"{CONTRACT_ID} attach_domain={SIGNED_ATTACH_DOMAIN} land_at={LAND_AT_STATUS}") + ensure_codex_mcp_config(cfg.mcp_url, CODEX_MCP_ENV_VAR, write=not DRY_RUN) + if DRY_RUN: + log( + f"DRY_RUN — would boot_session(runtime={RUNTIME_TYPE}), poll own-assignee open tasks, " + f"codex exec --sandbox {SANDBOX} --json, verify, PR, land_at_review" + ) + # Still resolve identity when a token exists so dry-run exercises the contract path. + try: + identity: RuntimeIdentity = boot_session( + cfg, + presence_adapter="codex", + presence_capabilities=["build"], + log=log, + ) + tasks = poll_open_tasks(cfg, identity, limit=MAX_TASKS) + log(f"{len(tasks)} open task(s) assigned to codex ({identity.agent_id})") + for task in tasks: + run_task(cfg, task) + except Exception as exc: # noqa: BLE001 - dry-run may lack live pot access + log(f"DRY_RUN boot/poll skipped ({exc})") + return 0 + + identity = boot_session( + cfg, + presence_adapter="codex", + presence_capabilities=["build"], + log=log, + ) + tasks = poll_open_tasks(cfg, identity, limit=MAX_TASKS) + log(f"{len(tasks)} open task(s) assigned to codex ({identity.agent_id})") + for task in tasks: + try: + run_task(cfg, task) + except Exception as exc: # noqa: BLE001 - one task's failure must not kill the loop + log(f"task {task.get('id')} errored: {exc}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/cursor-worker.py b/scripts/cursor-worker.py index 22d496e3..1afaa018 100755 --- a/scripts/cursor-worker.py +++ b/scripts/cursor-worker.py @@ -1,35 +1,40 @@ #!/usr/bin/env python3 -"""Headless cursor -> mupot loop driver. +"""Headless cursor -> mupot loop driver (runtime-adapter/v1 reference). -Turns the `cursor` agent (Grok, Cursor CLI) into a dispatchable technician that -picks up mupot tasks and returns branches for a human/Kasra gate. The loop is -trustworthy BY CONSTRUCTION: cursor never self-closes a task (mupot's no-self-close -guard, PR #417) and never touches the remote — the trusted driver does push/PR and -moves the task to `review` for Kasra-core to gate. cursor only writes code in an -isolated worktree. +Turns the `cursor` agent into a dispatchable technician that picks up mupot +tasks and returns branches for a human/Kasra gate. Conforms to +runtime-adapter/v1 (scripts/runtime_adapter_v1.py): declared runtime type +`cursor`, server-derived identity/tenant/capability, signed attach domain +`fleet-attach:v1` (bearer attach when no host key), land-at-review contract. + +The loop is trustworthy BY CONSTRUCTION: cursor never self-closes a task +(mupot's no-self-close guard, PR #417) and never touches the remote — the +trusted driver does push/PR and moves the task to `review` for Kasra-core to +gate. cursor only writes code in an isolated worktree. Flow per task (assignee = cursor, status = open): + 0. boot -> boot_context + attach(runtime=cursor) + Port-1 presence 1. claim -> task_update status=in_progress 2. isolate -> git worktree add -b cursor/task- main - 3. dispatch -> cursor-agent -p --force --trust --approve-mcps --workspace "" - 4. verify -> cursor must have committed; run tsc + tests (no fake-green) + 3. dispatch -> cursor-agent -p --force --trust --approve-mcps --workspace + 4. verify -> cursor must have committed; run tsc (no fake-green) 5. deliver -> driver pushes the branch + opens the PR (cursor never does) - 6. report -> task_update status=review, gate_owner set, PR linked + 6. report -> land_at_review (status=review, gate_owner set, PR linked) 7. notify -> ping Kasra-core to gate; remove the worktree (keep branch/PR) The driver NEVER merges or deploys. Kasra-core gates the PR and verdicts the task. Config (env): MUPOT_MCP default https://mupot.mumega.com/mcp + MUPOT_API_BASE default derived from MUPOT_MCP CURSOR_TOKEN default ~/.fleet/agents/cursor-member.token - CURSOR_AGENT_ID default af7a30b5-... (the cursor agent on the mumega pot) + CURSOR_KEY optional ~/.fleet/agents/.key for signed attach REPO default /home/mumega/mupot - GATE_OWNER default 'gate:kasra-core' (capability Kasra-core holds) + GATE_OWNER default 'gate:kasra-core' MODEL optional cursor --model override MAX_TASKS default 1 (per run) TIMEOUT default 1800 (seconds per cursor run) - SANDBOX '1' adds --sandbox enabled (recommended for untrusted tasks; - off by default so tsc/tests/git run unrestricted on our own repo) + SANDBOX '1' adds --sandbox enabled DRY_RUN '1' = poll + print, do nothing Usage: @@ -38,18 +43,35 @@ """ from __future__ import annotations -import json import os import subprocess import sys -import urllib.request from pathlib import Path -MUPOT_MCP = os.environ.get("MUPOT_MCP", "https://mupot.mumega.com/mcp") +# Allow `python3 scripts/cursor-worker.py` to import the sibling adapter module. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from runtime_adapter_v1 import ( # noqa: E402 + CONTRACT_ID, + LAND_AT_STATUS, + SIGNED_ATTACH_DOMAIN, + AdapterConfig, + RuntimeIdentity, + boot_session, + claim_in_progress, + config_from_env, + land_at_review, + poll_open_tasks, + report_blocked, +) + +RUNTIME_TYPE = "cursor" +AGENT_TYPE = "builder" +LIFECYCLE = "on_demand" + CURSOR_TOKEN_PATH = Path(os.environ.get("CURSOR_TOKEN", str(Path.home() / ".fleet/agents/cursor-member.token"))) -CURSOR_AGENT_ID = os.environ.get("CURSOR_AGENT_ID", "af7a30b5-c53a-4387-9ffa-18439888b700") +CURSOR_KEY_PATH = Path(os.environ.get("CURSOR_KEY", "")) if os.environ.get("CURSOR_KEY") else None REPO = Path(os.environ.get("REPO", "/home/mumega/mupot")) -GATE_OWNER = os.environ.get("GATE_OWNER", "gate:kasra-core") MODEL = os.environ.get("MODEL", "").strip() MAX_TASKS = int(os.environ.get("MAX_TASKS", "1")) TIMEOUT = int(os.environ.get("TIMEOUT", "1800")) @@ -64,36 +86,6 @@ def log(msg: str) -> None: print(f"[cursor-worker] {msg}", flush=True) -def token() -> str: - return CURSOR_TOKEN_PATH.read_text().strip() - - -def mcp(tool: str, args: dict) -> dict: - """Call a mupot MCP tool as the cursor agent. Returns the tool's `result`.""" - body = json.dumps( - {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}} - ).encode() - req = urllib.request.Request( - MUPOT_MCP, - data=body, - headers={ - "Authorization": f"Bearer {token()}", - "content-type": "application/json", - # CF error 1010 blocks the default Python-urllib UA as a bot signature. - "User-Agent": "cursor-worker/1.0 (+mupot)", - }, - method="POST", - ) - with urllib.request.urlopen(req, timeout=60) as resp: - payload = json.loads(resp.read()) - if "error" in payload: - raise RuntimeError(f"mupot {tool} error: {payload['error']}") - inner = json.loads(payload["result"]["content"][0]["text"]) - if not inner.get("ok", True): - raise RuntimeError(f"mupot {tool} not ok: {inner}") - return inner.get("result", inner) - - def git(*args: str, cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess: env = dict(os.environ) env.pop("GITHUB_TOKEN", None) # gh token shadow guard @@ -102,34 +94,6 @@ def git(*args: str, cwd: Path | None = None, check: bool = True) -> subprocess.C ) -def register_presence() -> None: - """Best-effort Port-1 self-registration so the concierge's dispatcher sees - cursor as an online 'build' capability. registerModule is an idempotent - upsert (src/registry/service.ts), so calling presence_register on every - cycle both (re-)registers and refreshes the heartbeat in one call — no - need to track "already registered this process" state, since each cycle - is a fresh one-shot process (operator-loop.sh invokes this script anew - every OPERATOR_INTERVAL). project_id: null is the always-open self bucket - (no project-access grant needed). Never raises: a presence failure must - not block real task work. - """ - try: - mcp("presence_register", { - "adapter": "cursor", - "kind": "agent_system", - "project_id": None, - "capabilities": ["build"], - }) - log("presence: registered/refreshed (adapter=cursor, capabilities=[build])") - except Exception as exc: # noqa: BLE001 - presence is best-effort, never fatal - log(f"presence_register failed (non-fatal): {exc}") - - -def poll_open_tasks() -> list[dict]: - res = mcp("task_list", {"assignee_agent_id": CURSOR_AGENT_ID, "status": "open", "limit": MAX_TASKS}) - return res.get("tasks", [])[:MAX_TASKS] - - def build_brief(task: dict, worktree: Path, branch: str) -> str: return "\n".join( [ @@ -186,10 +150,12 @@ def deliver(worktree: Path, branch: str, task: dict) -> str: env.pop("GITHUB_TOKEN", None) title = f"cursor: {task.get('title','')[:60]}" pr_body = ( - f"Dispatched to the `cursor` agent (Grok) headless via the mupot loop for task `{task['id']}`.\n\n" + f"Dispatched to the `cursor` agent headless via the mupot loop " + f"({CONTRACT_ID}, runtime={RUNTIME_TYPE}) for task `{task['id']}`.\n\n" f"Task done-when: {task.get('done_when','')}\n\n" "Driver verified: cursor committed real work + `tsc --noEmit` clean. " - "**Kasra-core gates this PR before merge** (the task is in `review`; cursor cannot self-close it)." + f"**Kasra-core gates this PR before merge** (task lands at `{LAND_AT_STATUS}`; " + "cursor cannot self-close it)." ) out = subprocess.run( ["gh", "pr", "create", "--repo", REPO_SLUG, "--base", "main", "--head", branch, @@ -202,17 +168,7 @@ def deliver(worktree: Path, branch: str, task: dict) -> str: return url -def report_review(task: dict, pr_url: str, note: str) -> None: - body = f"{task.get('body','')}\n\n---\ncursor loop -> review. PR: {pr_url}\n{note}" - mcp("task_update", {"task_id": task["id"], "status": "review", "gate_owner": GATE_OWNER, "body": body}) - - -def report_blocked(task: dict, reason: str) -> None: - body = f"{task.get('body','')}\n\n---\ncursor loop BLOCKED: {reason}" - mcp("task_update", {"task_id": task["id"], "status": "blocked", "body": body}) - - -def run_task(task: dict) -> None: +def run_task(cfg: AdapterConfig, task: dict) -> None: tid = task["id"] short = tid.split("-")[0] branch = f"cursor/task-{short}" @@ -222,7 +178,7 @@ def run_task(task: dict) -> None: log("DRY_RUN — would claim, dispatch cursor, verify, PR, review. Skipping.") return - mcp("task_update", {"task_id": tid, "status": "in_progress"}) + claim_in_progress(cfg, task_id=tid) WORKTREE_ROOT.mkdir(parents=True, exist_ok=True) git("worktree", "add", "-b", branch, str(worktree), "main") try: @@ -231,11 +187,19 @@ def run_task(task: dict) -> None: ok, note = verify(worktree, branch) if not ok: log(f"verify FAILED: {note}") - report_blocked(task, note) + report_blocked( + cfg, + task_id=tid, + body=f"{task.get('body','')}\n\n---\ncursor loop BLOCKED: {note}", + ) return pr_url = deliver(worktree, branch, task) - report_review(task, pr_url, note) - log(f"delivered -> review. PR: {pr_url}") + land_at_review( + cfg, + task_id=tid, + body=f"{task.get('body','')}\n\n---\ncursor loop -> {LAND_AT_STATUS}. PR: {pr_url}\n{note}", + ) + log(f"delivered -> {LAND_AT_STATUS}. PR: {pr_url}") _notify_kasra(task, pr_url) finally: git("worktree", "remove", str(worktree), "--force", cwd=REPO, check=False) @@ -258,12 +222,26 @@ def main() -> int: if not CURSOR_TOKEN_PATH.exists(): log(f"no cursor token at {CURSOR_TOKEN_PATH}") return 2 - register_presence() - tasks = poll_open_tasks() - log(f"{len(tasks)} open task(s) assigned to cursor") + cfg = config_from_env( + token_path=CURSOR_TOKEN_PATH, + runtime=RUNTIME_TYPE, + agent_type=AGENT_TYPE, + user_agent=f"cursor-worker/1.0 (+mupot; {CONTRACT_ID})", + lifecycle=LIFECYCLE, + key_path=CURSOR_KEY_PATH, + ) + log(f"{CONTRACT_ID} attach_domain={SIGNED_ATTACH_DOMAIN} land_at={LAND_AT_STATUS}") + identity: RuntimeIdentity = boot_session( + cfg, + presence_adapter="cursor", + presence_capabilities=["build"], + log=log, + ) + tasks = poll_open_tasks(cfg, identity, limit=MAX_TASKS) + log(f"{len(tasks)} open task(s) assigned to cursor ({identity.agent_id})") for task in tasks: try: - run_task(task) + run_task(cfg, task) except Exception as exc: # noqa: BLE001 - one task's failure must not kill the loop log(f"task {task.get('id')} errored: {exc}") return 0 diff --git a/scripts/mumcp-worker.py b/scripts/mumcp-worker.py index 3d674491..9b91c54e 100755 --- a/scripts/mumcp-worker.py +++ b/scripts/mumcp-worker.py @@ -1,62 +1,44 @@ #!/usr/bin/env python3 -"""Headless mumcp -> mupot loop driver. +"""Headless mumcp -> mupot loop driver (runtime-adapter/v1 reference). Connects the vps-mumcp agent (WordPress/Elementor automation, runs as -`mumcp-agent.service` -> Claude Code in tmux session `mumcp`, cwd -/mnt/HC_Volume_104325311/projects/sitepilotai) to mupot governance the same -way scripts/cursor-worker.py connects `cursor`: poll for open tasks assigned -to the mumcp agent, claim, dispatch the work to the real runtime, verify, -and hand off to a human/Kasra-core gate via `review` status. mumcp NEVER -publishes on its own — its own WordPress output governance already forces -DRAFT server-side (server-forced draft, human-approval-before-publish); this -loop does not change that, it only adds mupot as a second, parallel work -source alongside mumcp's existing GitHub-issues/BACKLOG.md flow. +`mumcp-agent.service` -> Claude Code in tmux session `mumcp`) to mupot +governance the same way scripts/cursor-worker.py connects `cursor`. Conforms +to runtime-adapter/v1 (scripts/runtime_adapter_v1.py): declared runtime type +`claude-code`, server-derived identity/tenant/capability/squad, signed attach +domain `fleet-attach:v1` (bearer attach when no host key), land-at-review +contract. mumcp NEVER publishes on its own — WordPress output stays DRAFT +server-side; this loop adds mupot as a parallel governed work source. Dispatch mechanism (decided after checking both options live on the host): - - The `mumcp` tmux session is a LIVE, long-running interactive Claude Code - REPL (already mid-conversation when checked) with no reliable - machine-readable "done" boundary. `tmux send-keys` into it would - interleave with whatever it is already doing and risks corrupting a - session a human may be watching. + - The `mumcp` tmux session is a LIVE interactive Claude Code REPL — tmux + send-keys would interleave and risk corrupting a watched session. - A headless `claude -p` run in the SAME project directory, with the SAME - flags the systemd unit uses to launch the interactive session - (`--model sonnet --dangerously-skip-permissions`), gets the identical - tool surface (project .mcp.json, CLAUDE.md, WordPress/MCPWP access) in a - fresh, isolated, scriptable process — exit code + captured stdout, no - interference with the live session. This mirrors the proven - cursor-worker.py pattern (`cursor-agent -p` headless), so: headless -p - is what this driver uses. tmux send-keys is not used. + flags the systemd unit uses (`--model sonnet --dangerously-skip-permissions`), + gets the identical tool surface in an isolated, scriptable process. + Mirrors cursor-worker.py (`cursor-agent -p` headless). Flow per task (assignee = mumcp agent, status = open): + 0. boot -> boot_context + attach(runtime=claude-code) + Port-1 presence 1. claim -> task_update status=in_progress - 2. dispatch -> `claude -p --output-format json` in MUMCP_PROJECT_DIR with a - brief that hard-instructs: WordPress changes land as DRAFT - only, never publish/merge; report a structured - MUMCP_RESULT JSON block at the end. - 3. verify -> parse stdout for the MUMCP_RESULT block; a claim of - "done"/"draft_created" with no evidence (post id / url / - command output) is treated as unverified, not success (no - fake green). - 4. review -> task_update status=review, gate_owner=gate:kasra-core, - draft evidence appended to the task body. - 5. notify -> best-effort bus ping to kasra; non-fatal if it fails. + 2. dispatch -> `claude -p --output-format json` in MUMCP_PROJECT_DIR + 3. verify -> parse MUMCP_RESULT; done without evidence = unverified + 4. review -> land_at_review (status=review, gate_owner, draft evidence) + 5. notify -> best-effort bus ping to kasra -The driver NEVER approves, publishes, merges, or deploys. Kasra-core (a -human-gated squad) verdicts the task via task_verdict; mumcp's own plugin- -side approval flow additionally gates any actual publish action. +The driver NEVER approves, publishes, merges, or deploys. Config (env): MUPOT_MCP default https://mupot.mumega.com/mcp + MUPOT_API_BASE default derived from MUPOT_MCP MUMCP_TOKEN default ~/.fleet/agents/mumega-mumcp-member.token - MUMCP_AGENT_ID default e6695da3-2e04-45b8-b4af-acddaa7c1438 (mumega-mumcp, verified - agent-bound via boot_context/orient — see PR description) - MUMCP_SQUAD_ID default c0e87a6d-77de-4cf8-89a3-79e5843cdd30 (MCPWP Core) + MUMCP_KEY optional host key for signed attach GATE_OWNER default 'gate:kasra-core' - MUMCP_PROJECT_DIR default /mnt/HC_Volume_104325311/projects/sitepilotai (mumcp runtime cwd) + MUMCP_PROJECT_DIR default /mnt/HC_Volume_104325311/projects/sitepilotai CLAUDE_BIN default 'claude' - MODEL default 'sonnet' (matches mumcp-agent.service) - MAX_TASKS default 1 (per run) - TIMEOUT default 1800 (seconds per headless run) + MODEL default 'sonnet' + MAX_TASKS default 1 + TIMEOUT default 1800 DRY_RUN '1' = poll + print, do nothing Usage: @@ -70,14 +52,31 @@ import re import subprocess import sys -import urllib.request from pathlib import Path -MUPOT_MCP = os.environ.get("MUPOT_MCP", "https://mupot.mumega.com/mcp") +# Allow `python3 scripts/mumcp-worker.py` to import the sibling adapter module. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from runtime_adapter_v1 import ( # noqa: E402 + CONTRACT_ID, + LAND_AT_STATUS, + SIGNED_ATTACH_DOMAIN, + AdapterConfig, + RuntimeIdentity, + boot_session, + claim_in_progress, + config_from_env, + land_at_review, + poll_open_tasks, + report_blocked, +) + +RUNTIME_TYPE = "claude-code" +AGENT_TYPE = "builder" +LIFECYCLE = "on_demand" + MUMCP_TOKEN_PATH = Path(os.environ.get("MUMCP_TOKEN", str(Path.home() / ".fleet/agents/mumega-mumcp-member.token"))) -MUMCP_AGENT_ID = os.environ.get("MUMCP_AGENT_ID", "e6695da3-2e04-45b8-b4af-acddaa7c1438") -MUMCP_SQUAD_ID = os.environ.get("MUMCP_SQUAD_ID", "c0e87a6d-77de-4cf8-89a3-79e5843cdd30") -GATE_OWNER = os.environ.get("GATE_OWNER", "gate:kasra-core") +MUMCP_KEY_PATH = Path(os.environ.get("MUMCP_KEY", "")) if os.environ.get("MUMCP_KEY") else None MUMCP_PROJECT_DIR = Path(os.environ.get("MUMCP_PROJECT_DIR", "/mnt/HC_Volume_104325311/projects/sitepilotai")) CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude") MODEL = os.environ.get("MODEL", "sonnet") @@ -92,67 +91,6 @@ def log(msg: str) -> None: print(f"[mumcp-worker] {msg}", flush=True) -def token() -> str: - return MUMCP_TOKEN_PATH.read_text().strip() - - -def mcp(tool: str, args: dict) -> dict: - """Call a mupot MCP tool as the mumcp agent. Returns the tool's `result`.""" - body = json.dumps( - {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}} - ).encode() - req = urllib.request.Request( - MUPOT_MCP, - data=body, - headers={ - "Authorization": f"Bearer {token()}", - "content-type": "application/json", - # CF error 1010 blocks the default Python-urllib UA as a bot signature. - "User-Agent": "mumcp-worker/1.0 (+mupot)", - }, - method="POST", - ) - with urllib.request.urlopen(req, timeout=60) as resp: - payload = json.loads(resp.read()) - if "error" in payload: - raise RuntimeError(f"mupot {tool} error: {payload['error']}") - inner = json.loads(payload["result"]["content"][0]["text"]) - if not inner.get("ok", True): - raise RuntimeError(f"mupot {tool} not ok: {inner}") - return inner.get("result", inner) - - -def register_presence() -> None: - """Best-effort Port-1 self-registration so the concierge's dispatcher sees - mumcp as an online 'build' capability. registerModule is an idempotent - upsert (src/registry/service.ts), so calling presence_register on every - cycle both (re-)registers and refreshes the heartbeat in one call — no - need to track "already registered this process" state, since each cycle - is a fresh one-shot process (operator-loop.sh invokes this script anew - every OPERATOR_INTERVAL). project_id: null is the always-open self bucket - (no project-access grant needed). Never raises: a presence failure must - not block real task work. - """ - try: - mcp("presence_register", { - "adapter": "mumcp", - "kind": "agent_system", - "project_id": None, - "capabilities": ["build"], - }) - log("presence: registered/refreshed (adapter=mumcp, capabilities=[build])") - except Exception as exc: # noqa: BLE001 - presence is best-effort, never fatal - log(f"presence_register failed (non-fatal): {exc}") - - -def poll_open_tasks() -> list[dict]: - res = mcp( - "task_list", - {"squad_id": MUMCP_SQUAD_ID, "assignee_agent_id": MUMCP_AGENT_ID, "status": "open", "limit": MAX_TASKS}, - ) - return res.get("tasks", [])[:MAX_TASKS] - - def build_brief(task: dict) -> str: return "\n".join( [ @@ -218,8 +156,7 @@ def extract_claude_text(proc: subprocess.CompletedProcess) -> str: def verify(proc: subprocess.CompletedProcess) -> tuple[bool, str, dict]: - """No fake green: require the structured MUMCP_RESULT block, status=done, AND - non-empty evidence. A bare status:"done" claim with no evidence is NOT verified.""" + """No fake green: require MUMCP_RESULT, status=done, AND non-empty evidence.""" if proc.returncode != 0: return False, f"headless claude exit {proc.returncode}: {(proc.stderr or '')[-800:]}", {} text = extract_claude_text(proc) @@ -237,22 +174,7 @@ def verify(proc: subprocess.CompletedProcess) -> tuple[bool, str, dict]: return True, result.get("summary", ""), result -def report_review(task: dict, result: dict) -> None: - body = ( - f"{task.get('body', '')}\n\n---\nmumcp loop -> review.\n" - f"summary: {result.get('summary', '')}\n" - f"evidence: {result.get('evidence', '')}\n" - f"draft_only: {result.get('draft_only', True)} -- NOT published; awaiting gate." - ) - mcp("task_update", {"task_id": task["id"], "status": "review", "gate_owner": GATE_OWNER, "body": body}) - - -def report_blocked(task: dict, reason: str) -> None: - body = f"{task.get('body', '')}\n\n---\nmumcp loop BLOCKED: {reason}" - mcp("task_update", {"task_id": task["id"], "status": "blocked", "body": body}) - - -def run_task(task: dict) -> None: +def run_task(cfg: AdapterConfig, task: dict) -> None: tid = task["id"] short = tid.split("-")[0] log(f"=== task {short}: {task.get('title', '')[:60]} ===") @@ -260,19 +182,36 @@ def run_task(task: dict) -> None: log("DRY_RUN -- would claim, dispatch headless claude, verify, move to review. Skipping.") return - mcp("task_update", {"task_id": tid, "status": "in_progress"}) + claim_in_progress(cfg, task_id=tid) try: proc = dispatch(task) except subprocess.TimeoutExpired: - report_blocked(task, f"headless claude timed out after {TIMEOUT}s") + report_blocked( + cfg, + task_id=tid, + body=f"{task.get('body', '')}\n\n---\nmumcp loop BLOCKED: headless claude timed out after {TIMEOUT}s", + ) return ok, note, result = verify(proc) if not ok: log(f"verify FAILED: {note}") - report_blocked(task, note) + report_blocked( + cfg, + task_id=tid, + body=f"{task.get('body', '')}\n\n---\nmumcp loop BLOCKED: {note}", + ) return - report_review(task, result) - log(f"delivered -> review. {note}") + land_at_review( + cfg, + task_id=tid, + body=( + f"{task.get('body', '')}\n\n---\nmumcp loop -> {LAND_AT_STATUS}.\n" + f"summary: {result.get('summary', '')}\n" + f"evidence: {result.get('evidence', '')}\n" + f"draft_only: {result.get('draft_only', True)} -- NOT published; awaiting gate." + ), + ) + log(f"delivered -> {LAND_AT_STATUS}. {note}") _notify_kasra(task, note) @@ -292,12 +231,29 @@ def main() -> int: if not MUMCP_TOKEN_PATH.exists(): log(f"no mumcp token at {MUMCP_TOKEN_PATH}") return 2 - register_presence() - tasks = poll_open_tasks() - log(f"{len(tasks)} open task(s) assigned to mumcp") + cfg = config_from_env( + token_path=MUMCP_TOKEN_PATH, + runtime=RUNTIME_TYPE, + agent_type=AGENT_TYPE, + user_agent=f"mumcp-worker/1.0 (+mupot; {CONTRACT_ID})", + lifecycle=LIFECYCLE, + key_path=MUMCP_KEY_PATH, + ) + log(f"{CONTRACT_ID} attach_domain={SIGNED_ATTACH_DOMAIN} land_at={LAND_AT_STATUS}") + identity: RuntimeIdentity = boot_session( + cfg, + presence_adapter="mumcp", + presence_capabilities=["build"], + log=log, + ) + # Own-assignee filter; squad omitted so mupot derives it from the bound agent + # (server-side). Optional MUMCP_SQUAD_ID remains a non-authority filter only. + squad_filter = os.environ.get("MUMCP_SQUAD_ID") or identity.squad_id + tasks = poll_open_tasks(cfg, identity, limit=MAX_TASKS, squad_id=squad_filter) + log(f"{len(tasks)} open task(s) assigned to mumcp ({identity.agent_id})") for task in tasks: try: - run_task(task) + run_task(cfg, task) except Exception as exc: # noqa: BLE001 - one task's failure must not kill the loop log(f"task {task.get('id')} errored: {exc}") return 0 diff --git a/scripts/runtime-adapter-driver-conformance.mjs b/scripts/runtime-adapter-driver-conformance.mjs new file mode 100644 index 00000000..432de729 --- /dev/null +++ b/scripts/runtime-adapter-driver-conformance.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * Offline conformance smoke for topology-A runtime-adapter/v1 drivers + * (cursor-worker.py + mumcp-worker.py + codex-worker.py + claude-code-worker.py). + * + * Does not require a live pot — asserts the shared adapter + drivers + * declare the contract, signed attach domain, land-at-review rails, and + * preserve the proven behavior markers. + * + * npm run conformance:runtime:drivers + */ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const read = (rel) => readFileSync(path.join(root, rel), 'utf8') + +const contract = 'runtime-adapter/v1' +const checks = [] + +function check(name, ok, detail = '') { + checks.push({ name, ok, detail }) + const mark = ok ? 'PASS' : 'FAIL' + console.log(`${mark} ${name}${detail ? ` — ${detail}` : ''}`) +} + +const adapter = read('scripts/runtime_adapter_v1.py') +const cursor = read('scripts/cursor-worker.py') +const mumcp = read('scripts/mumcp-worker.py') +const codex = read('scripts/codex-worker.py') +const claudeCode = read('scripts/claude-code-worker.py') +const attach = read('src/fleet/attach-routes.ts') +const codexToml = read('connectors/codex/config.toml') +const claudeMcp = read('connectors/claude/.mcp.json') +const flockMcp = read('packs/claude-code/flock-agent/.mcp.json.template') + +check('adapter declares contract id', adapter.includes(`CONTRACT_ID = "${contract}"`)) +check('adapter declares fleet-attach:v1', adapter.includes('SIGNED_ATTACH_DOMAIN = "fleet-attach:v1"')) +check('adapter lands at review', adapter.includes('LAND_AT_STATUS = "review"')) +check('adapter forbids merge/deploy/self_verdict', ['merge', 'deploy', 'self_verdict'].every((a) => adapter.includes(`"${a}"`))) +check('adapter resolves identity via boot_context', adapter.includes('boot_context') && adapter.includes('def resolve_identity(')) +check('adapter implements signed + bearer attach', adapter.includes('def signed_attach(') && adapter.includes('def bearer_attach(')) + +check('cursor-worker imports reference adapter', cursor.includes('from runtime_adapter_v1 import')) +check('cursor-worker declares runtime=cursor', cursor.includes('RUNTIME_TYPE = "cursor"')) +check('cursor-worker boots + lands at review', cursor.includes('boot_session(') && cursor.includes('land_at_review(')) +check('cursor-worker keeps worktree isolation', cursor.includes('worktree", "add"')) +check('cursor-worker keeps tsc verify', cursor.includes('npx", "tsc", "--noEmit"')) +check('cursor-worker driver opens PR', cursor.includes('gh", "pr", "create"')) +check('cursor-worker never self-verdicts', !cursor.includes('task_verdict')) +check('cursor-worker drops hardcoded AGENT_ID authority', !cursor.includes('CURSOR_AGENT_ID')) + +check('mumcp-worker imports reference adapter', mumcp.includes('from runtime_adapter_v1 import')) +check('mumcp-worker declares runtime=claude-code', mumcp.includes('RUNTIME_TYPE = "claude-code"')) +check('mumcp-worker boots + lands at review', mumcp.includes('boot_session(') && mumcp.includes('land_at_review(')) +check('mumcp-worker keeps draft-only rail', mumcp.includes('draft_only') && mumcp.includes('Never publish')) +check('mumcp-worker never self-verdicts', !mumcp.includes('task_verdict')) +check('mumcp-worker drops hardcoded AGENT_ID authority', !mumcp.includes('MUMCP_AGENT_ID')) + +check('codex-worker imports reference adapter', codex.includes('from runtime_adapter_v1 import')) +check('codex-worker declares runtime=codex', codex.includes('RUNTIME_TYPE = "codex"')) +check('codex-worker boots + lands at review', codex.includes('boot_session(') && codex.includes('land_at_review(')) +check('codex-worker dispatches via codex exec', codex.includes('"exec"') && codex.includes('"--sandbox"') && codex.includes('"--json"')) +check('codex-worker keeps worktree isolation', codex.includes('worktree", "add"')) +check('codex-worker keeps tsc verify', codex.includes('npx", "tsc", "--noEmit"')) +check('codex-worker driver opens PR', codex.includes('gh", "pr", "create"')) +check('codex-worker never self-verdicts', !codex.includes('task_verdict')) +check('codex-worker never deploys/merges', !codex.includes('npm run deploy') && !codex.includes('gh pr merge')) +check('codex-worker uses bearer_token_env_var MCP', codex.includes('bearer_token_env_var') && codex.includes('mcp_config_stanza') && codex.includes('streamable-HTTP')) +check('codex-worker has mint-attach dry-run path', codex.includes('run_mint_attach') && codex.includes('DRY_RUN')) +check( + 'codex connector is streamable-HTTP (no SSE)', + codexToml.includes('bearer_token_env_var') && + codexToml.includes('url = ') && + !/^type\s*=\s*"sse"/m.test(codexToml) && + !/^transport\s*=\s*"sse"/m.test(codexToml), +) + +check('claude-code-worker imports reference adapter', claudeCode.includes('from runtime_adapter_v1 import')) +check('claude-code-worker declares runtime=claude-code', claudeCode.includes('RUNTIME_TYPE = "claude-code"')) +check('claude-code-worker boots + lands at review', claudeCode.includes('boot_session(') && claudeCode.includes('land_at_review(')) +check( + 'claude-code-worker dispatches via claude -p stream-json', + claudeCode.includes('"-p"') && + claudeCode.includes('"--output-format"') && + claudeCode.includes('stream-json'), +) +check('claude-code-worker keeps worktree isolation', claudeCode.includes('worktree", "add"')) +check('claude-code-worker keeps tsc verify', claudeCode.includes('npx", "tsc", "--noEmit"')) +check('claude-code-worker driver opens PR', claudeCode.includes('gh", "pr", "create"')) +check('claude-code-worker never self-verdicts', !claudeCode.includes('task_verdict')) +check('claude-code-worker never deploys/merges', !claudeCode.includes('npm run deploy') && !claudeCode.includes('gh pr merge')) +check( + 'claude-code-worker writes type:http .mcp.json', + claudeCode.includes('mcp_json_document') && + claudeCode.includes('"type": "http"') && + claudeCode.includes('headers.Authorization') && + claudeCode.includes('Authorization'), +) +check('claude-code-worker has mint-attach dry-run path', claudeCode.includes('run_mint_attach') && claudeCode.includes('DRY_RUN')) +check( + 'claude connector .mcp.json is type:http (no SSE)', + claudeMcp.includes('"type": "http"') && + claudeMcp.includes('"Authorization"') && + !claudeMcp.includes('"type": "sse"'), +) +check( + 'flock-agent pack .mcp.json.template is type:http (no SSE)', + flockMcp.includes('"type": "http"') && + flockMcp.includes('"Authorization"') && + !flockMcp.includes('"type": "sse"'), +) + +check('pot accepts cursor runtime type', /VALID_RUNTIMES = new Set\(\[[\s\S]*'cursor'/.test(attach)) +check('pot accepts codex runtime type', /VALID_RUNTIMES = new Set\(\[[\s\S]*'codex'/.test(attach)) +check('pot accepts claude-code runtime type', /VALID_RUNTIMES = new Set\(\[[\s\S]*'claude-code'/.test(attach)) + +const failed = checks.filter((c) => !c.ok) +console.log('') +console.log(`runtime-adapter driver conformance: ${checks.length - failed.length}/${checks.length} passed (${contract})`) +if (failed.length) { + console.error('FAILED:', failed.map((f) => f.name).join(', ')) + process.exit(1) +} diff --git a/scripts/runtime_adapter_v1.py b/scripts/runtime_adapter_v1.py new file mode 100644 index 00000000..04707340 --- /dev/null +++ b/scripts/runtime_adapter_v1.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python3 +"""runtime-adapter/v1 — shared reference client for topology-A drivers. + +Contract: docs/runtime-adapter-contract.md + docs/runtime-adapter-v1.json + +This is the REFERENCE adapter every later harness copies. Drivers (cursor, +mumcp/claude-code, codex, …) import this module instead of hand-rolling +MCP/attach/land-at-review glue. Identity, tenant, and capabilities are ALWAYS +server-derived (boot_context / attach ack) — local config is input for proof, +never authority. + +Hard rails (non-negotiable): + - land work at status=review behind gate_owner + - never merge / deploy / publish / self-verdict + - declare a contract runtime type on attach + - signed attach uses domain fleet-attach:v1 (when a host key exists) +""" +from __future__ import annotations + +import base64 +import json +import os +import secrets +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Mapping + +CONTRACT_ID = "runtime-adapter/v1" +SIGNED_ATTACH_DOMAIN = "fleet-attach:v1" +SIGNED_DETACH_DOMAIN = "fleet-detach:v1" +SIGNED_INBOX_DOMAIN = "agent-inbox:v1" +LAND_AT_STATUS = "review" +ATTACH_PATH = "/api/fleet/attach" +ATTACH_SIGNED_PATH = "/api/fleet/attach-signed" +DETACH_PATH = "/api/fleet/detach" +DETACH_SIGNED_PATH = "/api/fleet/detach-signed" +LIFECYCLES = frozenset({"on_demand", "always_on"}) +# Adapter arms never take these actions — the gate / human does. +FORBIDDEN_ADAPTER_ACTIONS = frozenset({"merge", "deploy", "publish", "self_verdict", "approve"}) + + +@dataclass(frozen=True) +class RuntimeIdentity: + """Server-derived principal. Never construct from local env as authority.""" + + tenant: str + member_id: str + agent_id: str + capabilities: tuple[Mapping[str, Any], ...] + identity_status: str + slug: str | None + squad_id: str | None + + +@dataclass(frozen=True) +class AdapterConfig: + mcp_url: str + api_base_url: str + token: str + runtime: str + agent_type: str + lifecycle: str + user_agent: str + gate_owner: str + key_path: Path | None + + +LogFn = Callable[[str], None] + + +def api_base_from_mcp(mcp_url: str) -> str: + """Derive the pot HTTP base from the MCP endpoint URL.""" + base = mcp_url.rstrip("/") + if base.endswith("/mcp"): + return base[: -len("/mcp")] + return base + + +def read_token(token_path: Path) -> str: + token = token_path.read_text().strip() + if not token: + raise ValueError(f"empty token at {token_path}") + return token + + +def _http_json( + method: str, + url: str, + *, + headers: Mapping[str, str], + body: Mapping[str, Any] | None, + timeout: float, +) -> tuple[int, dict[str, Any]]: + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(url, data=data, headers=dict(headers), method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read() + payload: dict[str, Any] = json.loads(raw) if raw else {} + return int(resp.status), payload + except urllib.error.HTTPError as exc: + raw = exc.read() + try: + payload = json.loads(raw) if raw else {} + except json.JSONDecodeError: + payload = {"raw": raw.decode("utf-8", errors="replace")} + return int(exc.code), payload + + +def mcp_call(cfg: AdapterConfig, tool: str, args: Mapping[str, Any], *, timeout: float = 60.0) -> dict[str, Any]: + """Call a mupot MCP tool. Returns the tool's `result` object.""" + status, payload = _http_json( + "POST", + cfg.mcp_url, + headers={ + "Authorization": f"Bearer {cfg.token}", + "content-type": "application/json", + "User-Agent": cfg.user_agent, + }, + body={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": tool, "arguments": dict(args)}, + }, + timeout=timeout, + ) + if status >= 400: + raise RuntimeError(f"mupot {tool} HTTP {status}: {payload}") + if "error" in payload: + raise RuntimeError(f"mupot {tool} error: {payload['error']}") + try: + inner = json.loads(payload["result"]["content"][0]["text"]) + except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc: + raise RuntimeError(f"mupot {tool} malformed response: {exc}") from exc + if not inner.get("ok", True): + raise RuntimeError(f"mupot {tool} not ok: {inner}") + result = inner.get("result", inner) + if not isinstance(result, dict): + raise RuntimeError(f"mupot {tool} result is not an object: {type(result).__name__}") + return result + + +def resolve_identity(cfg: AdapterConfig) -> RuntimeIdentity: + """Derive tenant/member/agent/capabilities from the server (boot_context + orient). + + Local env AGENT_ID / TENANT are NEVER accepted as authority. + """ + boot = mcp_call(cfg, "boot_context", {}) + tenant = boot.get("tenant") + member_id = boot.get("member_id") + agent_id = boot.get("bound_agent_id") + identity_status = boot.get("identity_status") + caps_raw = boot.get("capabilities") or [] + if not isinstance(tenant, str) or not tenant: + raise RuntimeError("boot_context missing server-derived tenant") + if not isinstance(member_id, str) or not member_id: + raise RuntimeError("boot_context missing server-derived member_id") + if identity_status != "minted" or not isinstance(agent_id, str) or not agent_id: + raise RuntimeError( + f"runtime-adapter/v1 requires a minted agent-bound token; " + f"got identity_status={identity_status!r} bound_agent_id={agent_id!r}" + ) + if not isinstance(caps_raw, list): + raise RuntimeError("boot_context capabilities must be a list") + + slug: str | None = None + squad_id: str | None = None + orient = mcp_call(cfg, "orient", {}) + packet = orient.get("packet") if isinstance(orient.get("packet"), dict) else orient + agent = packet.get("agent") if isinstance(packet, dict) else None + squad = packet.get("squad") if isinstance(packet, dict) else None + if isinstance(agent, dict) and isinstance(agent.get("slug"), str): + slug = agent["slug"] + if isinstance(squad, dict) and isinstance(squad.get("id"), str): + squad_id = squad["id"] + if squad_id is None: + for cap in caps_raw: + if ( + isinstance(cap, dict) + and cap.get("scope_type") == "squad" + and isinstance(cap.get("scope_id"), str) + ): + squad_id = cap["scope_id"] + break + + return RuntimeIdentity( + tenant=tenant, + member_id=member_id, + agent_id=agent_id, + capabilities=tuple(c for c in caps_raw if isinstance(c, Mapping)), + identity_status=identity_status, + slug=slug, + squad_id=squad_id, + ) + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _load_ed25519_private_key(key_path: Path) -> Any: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import load_pem_private_key + + raw = key_path.read_bytes() + # Prefer JWK (fleet-runtime agent-keygen shape); fall back to PEM. + try: + jwk = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + jwk = None + if isinstance(jwk, dict) and jwk.get("kty") == "OKP" and jwk.get("crv") == "Ed25519": + d = jwk.get("d") + if not isinstance(d, str) or not d: + raise ValueError(f"Ed25519 JWK at {key_path} missing d") + pad = "=" * (-len(d) % 4) + seed = base64.urlsafe_b64decode(d + pad) + return Ed25519PrivateKey.from_private_bytes(seed) + key = load_pem_private_key(raw, password=None) + if not isinstance(key, Ed25519PrivateKey): + raise TypeError(f"key at {key_path} is not Ed25519") + return key + + +def canonical_attach_message( + *, + tenant: str, + agent_id: str, + agent_type: str, + runtime: str, + lifecycle: str, + ts: int, + nonce: str, +) -> bytes: + """Byte-identical to src/fleet/signed-attach.ts / fleet-runtime/fleet-sign.mjs.""" + return "\n".join( + [ + SIGNED_ATTACH_DOMAIN, + tenant, + agent_id, + agent_type, + runtime, + lifecycle, + str(ts), + nonce, + ] + ).encode("utf-8") + + +def signed_attach( + cfg: AdapterConfig, + identity: RuntimeIdentity, + *, + host: str = "", + timeout: float = 30.0, +) -> dict[str, Any]: + """POST /api/fleet/attach-signed with fleet-attach:v1 domain separation.""" + if cfg.key_path is None or not cfg.key_path.is_file(): + raise FileNotFoundError("signed_attach requires AdapterConfig.key_path") + if cfg.lifecycle not in LIFECYCLES: + raise ValueError(f"lifecycle must be one of {sorted(LIFECYCLES)}") + priv = _load_ed25519_private_key(cfg.key_path) + ts = int(time.time()) + nonce = _b64url(secrets.token_bytes(32)) + message = canonical_attach_message( + tenant=identity.tenant, + agent_id=identity.agent_id, + agent_type=cfg.agent_type, + runtime=cfg.runtime, + lifecycle=cfg.lifecycle, + ts=ts, + nonce=nonce, + ) + sig = _b64url(priv.sign(message)) + body: dict[str, Any] = { + "agent_id": identity.agent_id, + "type": cfg.agent_type, + "runtime": cfg.runtime, + "lifecycle": cfg.lifecycle, + "ts": ts, + "nonce": nonce, + "sig": sig, + "host": host if isinstance(host, str) else "", + } + status, payload = _http_json( + "POST", + f"{cfg.api_base_url.rstrip('/')}{ATTACH_SIGNED_PATH}", + headers={"content-type": "application/json", "User-Agent": cfg.user_agent}, + body=body, + timeout=timeout, + ) + if status >= 400 or not payload.get("ok", False): + raise RuntimeError(f"attach-signed failed HTTP {status}: {payload}") + return payload + + +def bearer_attach( + cfg: AdapterConfig, + identity: RuntimeIdentity, + *, + host: str = "", + timeout: float = 30.0, +) -> dict[str, Any]: + """POST /api/fleet/attach — token-welded agents without a registered key.""" + if cfg.lifecycle not in LIFECYCLES: + raise ValueError(f"lifecycle must be one of {sorted(LIFECYCLES)}") + body: dict[str, Any] = { + "agent_id": identity.agent_id, + "type": cfg.agent_type, + "runtime": cfg.runtime, + "lifecycle": cfg.lifecycle, + "host": host if isinstance(host, str) else "", + } + status, payload = _http_json( + "POST", + f"{cfg.api_base_url.rstrip('/')}{ATTACH_PATH}", + headers={ + "Authorization": f"Bearer {cfg.token}", + "content-type": "application/json", + "User-Agent": cfg.user_agent, + }, + body=body, + timeout=timeout, + ) + if status >= 400 or not payload.get("ok", False): + raise RuntimeError(f"bearer attach failed HTTP {status}: {payload}") + return payload + + +def attach(cfg: AdapterConfig, identity: RuntimeIdentity, *, host: str = "") -> dict[str, Any]: + """Prefer signed attach (fleet-attach:v1) when a host key exists; else bearer.""" + if cfg.key_path is not None and cfg.key_path.is_file(): + return signed_attach(cfg, identity, host=host) + return bearer_attach(cfg, identity, host=host) + + +def land_at_review( + cfg: AdapterConfig, + *, + task_id: str, + body: str, + gate_owner: str | None = None, +) -> dict[str, Any]: + """Land work at status=review. Adapters never merge/deploy/self-verdict.""" + owner = gate_owner if gate_owner is not None else cfg.gate_owner + return mcp_call( + cfg, + "task_update", + { + "task_id": task_id, + "status": LAND_AT_STATUS, + "gate_owner": owner, + "body": body, + }, + ) + + +def report_blocked(cfg: AdapterConfig, *, task_id: str, body: str) -> dict[str, Any]: + return mcp_call(cfg, "task_update", {"task_id": task_id, "status": "blocked", "body": body}) + + +def claim_in_progress(cfg: AdapterConfig, *, task_id: str) -> dict[str, Any]: + return mcp_call(cfg, "task_update", {"task_id": task_id, "status": "in_progress"}) + + +def poll_open_tasks( + cfg: AdapterConfig, + identity: RuntimeIdentity, + *, + limit: int, + squad_id: str | None = None, +) -> list[dict[str, Any]]: + """Own-assignee filter only — server-derived agent_id, never a local guess.""" + args: dict[str, Any] = { + "assignee_agent_id": identity.agent_id, + "status": "open", + "limit": limit, + } + if squad_id: + args["squad_id"] = squad_id + res = mcp_call(cfg, "task_list", args) + tasks = res.get("tasks", []) + if not isinstance(tasks, list): + raise RuntimeError("task_list.tasks must be a list") + return [t for t in tasks if isinstance(t, dict)][:limit] + + +def register_port1_presence( + cfg: AdapterConfig, + *, + adapter: str, + capabilities: list[str], + log: LogFn, +) -> None: + """Best-effort Port-1 self-registration (concierge dispatcher). Non-fatal.""" + try: + mcp_call( + cfg, + "presence_register", + { + "adapter": adapter, + "kind": "agent_system", + "project_id": None, + "capabilities": capabilities, + }, + ) + log(f"presence: registered/refreshed (adapter={adapter}, capabilities={capabilities})") + except Exception as exc: # noqa: BLE001 - presence is best-effort, never fatal + log(f"presence_register failed (non-fatal): {exc}") + + +def boot_session( + cfg: AdapterConfig, + *, + presence_adapter: str, + presence_capabilities: list[str], + log: LogFn, + host: str = "", +) -> RuntimeIdentity: + """Resolve identity (server) → attach (declared runtime) → Port-1 presence.""" + identity = resolve_identity(cfg) + log( + f"{CONTRACT_ID} identity tenant={identity.tenant} " + f"agent={identity.agent_id} slug={identity.slug!r} " + f"runtime={cfg.runtime} lifecycle={cfg.lifecycle}" + ) + try: + ack = attach(cfg, identity, host=host) + agent_view = ack.get("agent") if isinstance(ack.get("agent"), dict) else {} + log( + f"attach ok runtime={agent_view.get('runtime', cfg.runtime)!r} " + f"status={agent_view.get('status', 'running')!r}" + ) + except Exception as exc: # noqa: BLE001 - attach failure must not block task work + log(f"attach failed (non-fatal this cycle): {exc}") + register_port1_presence( + cfg, + adapter=presence_adapter, + capabilities=presence_capabilities, + log=log, + ) + return identity + + +def config_from_env( + *, + token_path: Path, + runtime: str, + agent_type: str, + user_agent: str, + mcp_url: str | None = None, + api_base_url: str | None = None, + lifecycle: str | None = None, + gate_owner: str | None = None, + key_path: Path | None = None, +) -> AdapterConfig: + mcp = mcp_url if mcp_url is not None else os.environ.get("MUPOT_MCP", "https://mupot.mumega.com/mcp") + api = api_base_url if api_base_url is not None else os.environ.get("MUPOT_API_BASE") + if not api: + api = api_base_from_mcp(mcp) + life = lifecycle if lifecycle is not None else os.environ.get("LIFECYCLE", "on_demand") + if life not in LIFECYCLES: + raise ValueError(f"lifecycle must be one of {sorted(LIFECYCLES)}, got {life!r}") + gate = gate_owner if gate_owner is not None else os.environ.get("GATE_OWNER", "gate:kasra-core") + return AdapterConfig( + mcp_url=mcp, + api_base_url=api, + token=read_token(token_path), + runtime=runtime, + agent_type=agent_type, + lifecycle=life, + user_agent=user_agent, + gate_owner=gate, + key_path=key_path, + ) diff --git a/src/fleet/attach-routes.ts b/src/fleet/attach-routes.ts index df69dfcd..da923343 100644 --- a/src/fleet/attach-routes.ts +++ b/src/fleet/attach-routes.ts @@ -117,7 +117,7 @@ const VALID_TYPES = new Set(['builder', 'reviewer', 'weaver', 'brain', 'comms', // Attach runtimes include 'hermes' (standalone Hermes agent runtime, absent from the // daemon-report set which uses 'hermes-cron' for cron-only Hermes). const VALID_RUNTIMES = new Set([ - 'codex', 'claude-code', 'nous', 'hermes', 'hermes-cron', + 'codex', 'claude-code', 'cursor', 'nous', 'hermes', 'hermes-cron', 'systemd-user', 'tmux', 'python', ]) diff --git a/src/fleet/registry.ts b/src/fleet/registry.ts index e7c67c1a..e4305c1a 100644 --- a/src/fleet/registry.ts +++ b/src/fleet/registry.ts @@ -10,7 +10,7 @@ import { resolveCapabilities } from '../auth/capability' const AGENT_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/ const STATUSES = new Set(['running', 'stopped', 'unknown']) -const RUNTIMES = new Set(['codex', 'claude-code', 'nous', 'hermes-cron', 'systemd-user', 'tmux', 'python', '']) +const RUNTIMES = new Set(['codex', 'claude-code', 'cursor', 'nous', 'hermes-cron', 'systemd-user', 'tmux', 'python', '']) const LIFECYCLES = new Set(['on_demand', 'always_on', '']) // Valid agent type values — what KIND of agent, not the runtime it runs on. const AGENT_TYPES = new Set(['builder', 'reviewer', 'weaver', 'brain', 'comms', 'generic']) diff --git a/tests/runtime-adapter-contract.test.ts b/tests/runtime-adapter-contract.test.ts index a366e65c..694f47a1 100644 --- a/tests/runtime-adapter-contract.test.ts +++ b/tests/runtime-adapter-contract.test.ts @@ -296,6 +296,7 @@ describe('runtime-adapter/v1 contract artifact', () => { const scriptsReadme = readFileSync(new URL('../scripts/README.md', import.meta.url), 'utf8') expect(pkg.scripts['conformance:runtime:local']).toBe('node scripts/local-runtime-conformance.mjs') + expect(pkg.scripts['conformance:runtime:drivers']).toBe('node scripts/runtime-adapter-driver-conformance.mjs') expect(pkg.scripts['dev:local:test']).toContain('wrangler dev --local') expect(script).toContain('runtime-adapter/v1') expect(script).toContain('/api/fleet/attach-signed') @@ -315,9 +316,17 @@ describe('runtime-adapter/v1 contract artifact', () => { expect(seed).toContain('tenant = excluded.tenant') expect(seed).toContain('5hhsUxlkZWNACkMQjUFNIO1-e4bbFtTaLUd7_5L7sdU') expect(scriptsReadme).toContain('npm run conformance:runtime:local') + expect(scriptsReadme).toContain('npm run conformance:runtime:drivers') expect(scriptsReadme).toContain('signed attach') expect(scriptsReadme).toContain('fleet control') expect(scriptsReadme).toContain('signed detach') + expect(scriptsReadme).toContain('runtime_adapter_v1.py') + + const driverSmoke = readFileSync(new URL('../scripts/runtime-adapter-driver-conformance.mjs', import.meta.url), 'utf8') + expect(driverSmoke).toContain('runtime-adapter/v1') + expect(driverSmoke).toContain('fleet-attach:v1') + expect(driverSmoke).toContain('cursor-worker.py') + expect(driverSmoke).toContain('mumcp-worker.py') }) it('keeps local browser and runtime evidence wired into CI', () => { diff --git a/tests/runtime-adapter-drivers.test.ts b/tests/runtime-adapter-drivers.test.ts new file mode 100644 index 00000000..3ff7c27d --- /dev/null +++ b/tests/runtime-adapter-drivers.test.ts @@ -0,0 +1,168 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const root = new URL('..', import.meta.url) +const read = (rel: string) => readFileSync(new URL(rel, root), 'utf8') + +const adapter = read('scripts/runtime_adapter_v1.py') +const cursorWorker = read('scripts/cursor-worker.py') +const mumcpWorker = read('scripts/mumcp-worker.py') +const codexWorker = read('scripts/codex-worker.py') +const claudeCodeWorker = read('scripts/claude-code-worker.py') +const attachRoutes = read('src/fleet/attach-routes.ts') +const contractMd = read('docs/runtime-adapter-contract.md') +const codexToml = read('connectors/codex/config.toml') +const codexReadme = read('connectors/codex/README.md') +const claudeMcp = read('connectors/claude/.mcp.json') +const claudeReadme = read('connectors/claude/README.md') +const flockMcp = read('packs/claude-code/flock-agent/.mcp.json.template') + +describe('runtime-adapter/v1 reference drivers (BYOA topology A)', () => { + it('ships a shared reference adapter declaring the contract + signed attach domain', () => { + expect(adapter).toContain('CONTRACT_ID = "runtime-adapter/v1"') + expect(adapter).toContain('SIGNED_ATTACH_DOMAIN = "fleet-attach:v1"') + expect(adapter).toContain('SIGNED_DETACH_DOMAIN = "fleet-detach:v1"') + expect(adapter).toContain('SIGNED_INBOX_DOMAIN = "agent-inbox:v1"') + expect(adapter).toContain('LAND_AT_STATUS = "review"') + expect(adapter).toContain('ATTACH_SIGNED_PATH = "/api/fleet/attach-signed"') + expect(adapter).toContain('ATTACH_PATH = "/api/fleet/attach"') + expect(adapter).toContain('def resolve_identity(') + expect(adapter).toContain('boot_context') + expect(adapter).toContain('def signed_attach(') + expect(adapter).toContain('def bearer_attach(') + expect(adapter).toContain('def land_at_review(') + expect(adapter).toContain('FORBIDDEN_ADAPTER_ACTIONS') + expect(adapter).toContain('"merge"') + expect(adapter).toContain('"deploy"') + expect(adapter).toContain('"self_verdict"') + }) + + it('refactors cursor-worker onto the reference adapter with runtime=cursor', () => { + expect(cursorWorker).toContain('from runtime_adapter_v1 import') + expect(cursorWorker).toContain('CONTRACT_ID') + expect(cursorWorker).toContain('SIGNED_ATTACH_DOMAIN') + expect(cursorWorker).toContain('RUNTIME_TYPE = "cursor"') + expect(cursorWorker).toContain('boot_session(') + expect(cursorWorker).toContain('poll_open_tasks(') + expect(cursorWorker).toContain('land_at_review(') + expect(cursorWorker).toContain('claim_in_progress(') + // Behavior rails preserved. + expect(cursorWorker).toContain('worktree", "add"') + expect(cursorWorker).toContain('npx", "tsc", "--noEmit"') + expect(cursorWorker).toContain('gh", "pr", "create"') + expect(cursorWorker).toContain('Do NOT push, do NOT open a PR') + expect(cursorWorker).not.toContain('CURSOR_AGENT_ID') + expect(cursorWorker).not.toContain('task_verdict') + expect(cursorWorker).not.toMatch(/npm run deploy|gh pr merge/) + }) + + it('keeps own-assignee polling on the shared adapter (server-derived agent_id)', () => { + expect(adapter).toContain('"assignee_agent_id": identity.agent_id') + expect(adapter).toContain('Own-assignee filter') + }) + + it('refactors mumcp-worker onto the reference adapter with runtime=claude-code', () => { + expect(mumcpWorker).toContain('from runtime_adapter_v1 import') + expect(mumcpWorker).toContain('CONTRACT_ID') + expect(mumcpWorker).toContain('SIGNED_ATTACH_DOMAIN') + expect(mumcpWorker).toContain('RUNTIME_TYPE = "claude-code"') + expect(mumcpWorker).toContain('boot_session(') + expect(mumcpWorker).toContain('poll_open_tasks(') + expect(mumcpWorker).toContain('land_at_review(') + expect(mumcpWorker).toContain('MUMCP_RESULT') + expect(mumcpWorker).toContain('draft_only') + expect(mumcpWorker).toContain('Never publish') + expect(mumcpWorker).not.toContain('MUMCP_AGENT_ID') + expect(mumcpWorker).not.toContain('task_verdict') + expect(mumcpWorker).not.toMatch(/npm run deploy|gh pr merge/) + }) + + it('ships codex-worker on the reference adapter with runtime=codex (BYOA slice 2)', () => { + expect(codexWorker).toContain('from runtime_adapter_v1 import') + expect(codexWorker).toContain('CONTRACT_ID') + expect(codexWorker).toContain('SIGNED_ATTACH_DOMAIN') + expect(codexWorker).toContain('RUNTIME_TYPE = "codex"') + expect(codexWorker).toContain('boot_session(') + expect(codexWorker).toContain('poll_open_tasks(') + expect(codexWorker).toContain('land_at_review(') + expect(codexWorker).toContain('claim_in_progress(') + expect(codexWorker).toContain('"exec"') + expect(codexWorker).toContain('"--sandbox"') + expect(codexWorker).toContain('"--json"') + expect(codexWorker).toContain('worktree", "add"') + expect(codexWorker).toContain('npx", "tsc", "--noEmit"') + expect(codexWorker).toContain('gh", "pr", "create"') + expect(codexWorker).toContain('bearer_token_env_var') + expect(codexWorker).toContain('run_mint_attach') + expect(codexWorker).toContain('Do NOT push, do NOT open a PR') + expect(codexWorker).not.toContain('CODEX_AGENT_ID') + expect(codexWorker).not.toContain('task_verdict') + expect(codexWorker).not.toMatch(/npm run deploy|gh pr merge/) + expect(codexWorker).toContain('mcp_config_stanza') + expect(codexWorker).toContain('streamable-HTTP') + }) + + it('wires Codex remote MCP as streamable-HTTP (url + bearer_token_env_var, no SSE)', () => { + expect(codexToml).toContain('bearer_token_env_var') + expect(codexToml).toContain('url = ') + expect(codexToml).not.toMatch(/^type\s*=\s*"sse"/m) + expect(codexToml).not.toMatch(/^transport\s*=\s*"sse"/m) + expect(codexReadme).toContain('Streamable-HTTP') + expect(codexReadme).toContain('bearer_token_env_var') + expect(codexReadme).not.toMatch(/^type = "sse"$/m) + }) + + it('ships claude-code-worker on the reference adapter with runtime=claude-code (BYOA slice 3)', () => { + expect(claudeCodeWorker).toContain('from runtime_adapter_v1 import') + expect(claudeCodeWorker).toContain('CONTRACT_ID') + expect(claudeCodeWorker).toContain('SIGNED_ATTACH_DOMAIN') + expect(claudeCodeWorker).toContain('RUNTIME_TYPE = "claude-code"') + expect(claudeCodeWorker).toContain('boot_session(') + expect(claudeCodeWorker).toContain('poll_open_tasks(') + expect(claudeCodeWorker).toContain('land_at_review(') + expect(claudeCodeWorker).toContain('claim_in_progress(') + expect(claudeCodeWorker).toContain('"-p"') + expect(claudeCodeWorker).toContain('"--output-format"') + expect(claudeCodeWorker).toContain('stream-json') + expect(claudeCodeWorker).toContain('worktree", "add"') + expect(claudeCodeWorker).toContain('npx", "tsc", "--noEmit"') + expect(claudeCodeWorker).toContain('gh", "pr", "create"') + expect(claudeCodeWorker).toContain('mcp_json_document') + expect(claudeCodeWorker).toContain('"type": "http"') + expect(claudeCodeWorker).toContain('headers.Authorization') + expect(claudeCodeWorker).toContain('run_mint_attach') + expect(claudeCodeWorker).toContain('Do NOT push, do NOT open a PR') + expect(claudeCodeWorker).not.toContain('CLAUDE_CODE_AGENT_ID') + expect(claudeCodeWorker).not.toContain('task_verdict') + expect(claudeCodeWorker).not.toMatch(/npm run deploy|gh pr merge/) + }) + + it('wires Claude Code remote MCP as type:http (.mcp.json + headers.Authorization, no SSE)', () => { + expect(claudeMcp).toContain('"type": "http"') + expect(claudeMcp).toContain('"Authorization"') + expect(claudeMcp).not.toContain('"type": "sse"') + expect(flockMcp).toContain('"type": "http"') + expect(flockMcp).toContain('"Authorization"') + expect(flockMcp).not.toContain('"type": "sse"') + expect(claudeReadme).toContain('type: "http"') + expect(claudeReadme).toContain('claude-code-worker.py') + expect(claudeReadme).toContain('stream-json') + }) + + it('accepts cursor, codex, and claude-code as declared attach runtime types on the pot', () => { + expect(attachRoutes).toMatch(/VALID_RUNTIMES = new Set\(\[[\s\S]*'cursor'/) + expect(attachRoutes).toMatch(/VALID_RUNTIMES = new Set\(\[[\s\S]*'codex'/) + expect(attachRoutes).toMatch(/VALID_RUNTIMES = new Set\(\[[\s\S]*'claude-code'/) + expect(contractMd).toContain('`cursor`') + expect(contractMd).toContain('`codex`') + expect(contractMd).toContain('`claude-code`') + }) + + it('keeps topology-A workers on the shared adapter (no bespoke glue outside the contract)', () => { + expect(cursorWorker).toContain('runtime-adapter/v1') + expect(mumcpWorker).toContain('runtime-adapter/v1') + expect(codexWorker).toContain('runtime-adapter/v1') + expect(claudeCodeWorker).toContain('runtime-adapter/v1') + expect(adapter).toContain('REFERENCE adapter') + }) +})