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/scripts/README.md b/scripts/README.md index 40ecb12a..25a5e5f5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -78,6 +78,27 @@ 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 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`), `scripts/codex-worker.py` (runtime=`codex`, +`codex exec --sandbox --json`, remote MCP via `~/.codex/config.toml` +`url` + `bearer_token_env_var` — no SSE). + +```bash +# Codex mint+attach plan (no live Codex creds required): +MINT_ATTACH=1 DRY_RUN=1 python3 scripts/codex-worker.py +``` + ## CI local evidence GitHub Actions runs the same local evidence gate with: diff --git a/scripts/codex-worker.py b/scripts/codex-worker.py new file mode 100755 index 00000000..9abf2594 --- /dev/null +++ b/scripts/codex-worker.py @@ -0,0 +1,454 @@ +#!/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 disallowed) + 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, +) +from codex_child_env import ( # noqa: E402 + build_codex_child_env, + resolve_sandbox, +) + +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") + +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: + sandbox = resolve_sandbox(SANDBOX) + cmd = [ + CODEX_BIN, + "exec", + "--sandbox", + sandbox, + "--json", + "-C", + str(worktree), + ] + if MODEL: + cmd += ["--model", MODEL] + cmd.append(brief) + env = build_codex_child_env( + dict(os.environ), + mcp_env_var=CODEX_MCP_ENV_VAR, + token=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", + 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) + + try: + sandbox = resolve_sandbox(SANDBOX) + except ValueError as exc: + log(str(exc)) + 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", + 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", + 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/codex_child_env.py b/scripts/codex_child_env.py new file mode 100644 index 00000000..7dad0894 --- /dev/null +++ b/scripts/codex_child_env.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Pure helpers for Codex topology-A child env + sandbox caps. + +Kept separate from codex-worker.py so offline conformance can import without +running the driver entrypoint. +""" +from __future__ import annotations + +from typing import Mapping + +VALID_SANDBOX = frozenset({"read-only", "workspace-write"}) +DISALLOWED_SANDBOX = frozenset({"danger-full-access"}) + +# Minimal allowlist for the untrusted `codex exec` child. Never inherit the +# operator env (GITHUB/cloud/deploy creds are an exfil path via prompt injection). +CODEX_CHILD_ENV_ALLOWLIST = frozenset( + {"PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR", "USER", "CODEX_HOME"} +) +FORBIDDEN_CHILD_ENV_PREFIXES = ( + "GITHUB_", + "GH_", + "AWS_", + "CLOUDFLARE_", + "CF_", + "DEPLOY_", + "WRANGLER_", + "NPM_TOKEN", + "NODE_AUTH_TOKEN", +) + + +def resolve_sandbox(raw: str) -> str: + """Cap topology-A at workspace-write; refuse danger-full-access.""" + mode = raw.strip() + if mode in DISALLOWED_SANDBOX: + raise ValueError( + f"SANDBOX={mode!r} is disallowed for topology-A (arms-never-deploy); " + f"cap at workspace-write (allowed: {sorted(VALID_SANDBOX)})" + ) + if mode not in VALID_SANDBOX: + raise ValueError(f"SANDBOX must be one of {sorted(VALID_SANDBOX)}, got {mode!r}") + return mode + + +def build_codex_child_env( + parent_env: Mapping[str, str], + *, + mcp_env_var: str, + token: str, +) -> dict[str, str]: + """Minimal allowlisted env for `codex exec` — no GitHub/cloud/deploy creds.""" + if not mcp_env_var or not isinstance(mcp_env_var, str): + raise ValueError("mcp_env_var required") + if not token or not isinstance(token, str): + raise ValueError("token required") + child: dict[str, str] = {} + for key in CODEX_CHILD_ENV_ALLOWLIST: + value = parent_env.get(key) + if isinstance(value, str) and value: + child[key] = value + # Defense: never copy a forbidden credential even if someone adds it to the allowlist. + for key in list(child): + upper = key.upper() + if upper in {"GITHUB_TOKEN", "GH_TOKEN"} or any( + upper.startswith(p) for p in FORBIDDEN_CHILD_ENV_PREFIXES + ): + raise RuntimeError(f"codex child env allowlist must not include forbidden key {key!r}") + child[mcp_env_var] = token + if "PATH" not in child: + raise RuntimeError("codex child env requires PATH") + if "HOME" not in child: + raise RuntimeError("codex child env requires HOME") + return child diff --git a/scripts/cursor-worker.py b/scripts/cursor-worker.py index 22d496e3..7526eb49 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,25 @@ 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", + 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..fc52cec5 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,28 @@ 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", + 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-proof.py b/scripts/runtime-adapter-driver-conformance-proof.py new file mode 100644 index 00000000..d78b2a93 --- /dev/null +++ b/scripts/runtime-adapter-driver-conformance-proof.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Offline behavioral proofs for runtime-adapter/v1 (not source-string greps). + +Exercises: + 1. attach/signature failure is TERMINAL before presence or dispatch + 2. codex child env is allowlisted (no GitHub/cloud/deploy creds) + 3. detach + inbox domains are implemented (canonical bytes + call paths) + +Invoked by scripts/runtime-adapter-driver-conformance.mjs. +""" +from __future__ import annotations + +import sys +import traceback +from pathlib import Path +from typing import Any +from unittest.mock import patch + +SCRIPTS = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPTS)) + +from runtime_adapter_v1 import ( # noqa: E402 + SIGNED_ATTACH_DOMAIN, + SIGNED_DETACH_DOMAIN, + SIGNED_INBOX_DOMAIN, + AdapterConfig, + RuntimeIdentity, + attach, + boot_session, + bearer_detach, + canonical_attach_message, + canonical_detach_message, + canonical_inbox_message, + detach, + signed_detach, + signed_inbox, +) +from codex_child_env import ( # noqa: E402 + build_codex_child_env, + resolve_sandbox, +) + + +def fail(name: str, detail: str) -> None: + print(f"FAIL {name} — {detail}") + raise SystemExit(1) + + +def ok(name: str, detail: str = "") -> None: + print(f"PASS {name}{f' — {detail}' if detail else ''}") + + +def _cfg(*, key_path: Path | None = None) -> AdapterConfig: + return AdapterConfig( + mcp_url="https://example.test/mcp", + api_base_url="https://example.test", + token="tok-test", + runtime="codex", + agent_type="builder", + lifecycle="on_demand", + user_agent="conformance-proof/1.0", + gate_owner="gate:kasra-core", + key_path=key_path, + ) + + +def _identity() -> RuntimeIdentity: + return RuntimeIdentity( + tenant="tenant-a", + member_id="member-a", + agent_id="agent-a", + capabilities=(), + identity_status="minted", + slug="codex", + squad_id="squad-core", + ) + + +def proof_attach_fail_closed() -> None: + """Attach failure must raise before presence_register / return.""" + cfg = _cfg() + identity = _identity() + logs: list[str] = [] + presence_calls: list[Any] = [] + + def fake_resolve(_cfg: AdapterConfig) -> RuntimeIdentity: + return identity + + def fake_attach(_cfg: AdapterConfig, _id: RuntimeIdentity, *, host: str = "") -> dict[str, Any]: + raise RuntimeError("attach-signed failed HTTP 401: {'error': 'unauthorized'}") + + def fake_presence(cfg: AdapterConfig, *, adapter: str, log: Any) -> None: + presence_calls.append(adapter) + log(f"presence would register {adapter}") + + with ( + patch("runtime_adapter_v1.resolve_identity", fake_resolve), + patch("runtime_adapter_v1.attach", fake_attach), + patch("runtime_adapter_v1.register_port1_presence", fake_presence), + ): + raised: Exception | None = None + try: + boot_session(cfg, presence_adapter="codex", log=logs.append) + except Exception as exc: # noqa: BLE001 - under test + raised = exc + + if raised is None: + fail("attach fail-closed", "boot_session returned despite attach failure") + if "401" not in str(raised) and "unauthorized" not in str(raised).lower(): + fail("attach fail-closed", f"unexpected error: {raised}") + if presence_calls: + fail("attach fail-closed", f"presence ran after attach failure: {presence_calls}") + if any("non-fatal" in line for line in logs): + fail("attach fail-closed", "logs still treat attach failure as non-fatal") + ok("attach fail-closed", "boot_session raised; presence not called") + + +def proof_signed_attach_bad_sig_path() -> None: + """signed_attach surfaces HTTP 401 — callers must not swallow it.""" + cfg = _cfg(key_path=Path("/nonexistent/key.jwk")) + # Force the signed path by pretending the key file exists + loading a fake key, + # then returning 401 from HTTP — proves signature-failure shape is terminal. + identity = _identity() + + class _FakeKey: + def sign(self, message: bytes) -> bytes: + return b"\x00" * 64 + + with ( + patch("runtime_adapter_v1.Path.is_file", return_value=True), + patch("runtime_adapter_v1._load_ed25519_private_key", return_value=_FakeKey()), + patch( + "runtime_adapter_v1._http_json", + return_value=(401, {"ok": False, "error": "unauthorized"}), + ), + ): + try: + attach(cfg, identity) + fail("signed attach bad-sig raises", "attach did not raise on HTTP 401") + except RuntimeError as exc: + if "401" not in str(exc): + fail("signed attach bad-sig raises", f"wrong error: {exc}") + ok("signed attach bad-sig raises", "HTTP 401 → RuntimeError") + + +def proof_child_env_scrub() -> None: + parent = { + "PATH": "/usr/bin", + "HOME": "/home/agent", + "LANG": "C.UTF-8", + "GITHUB_TOKEN": "ghp_secret", + "GH_TOKEN": "gh_secret", + "AWS_SECRET_ACCESS_KEY": "aws_secret", + "CLOUDFLARE_API_TOKEN": "cf_secret", + "CF_API_TOKEN": "cf_secret2", + "DEPLOY_KEY": "deploy_secret", + "WRANGLER_SEND_METRICS": "false", + "NPM_TOKEN": "npm_secret", + "NODE_AUTH_TOKEN": "node_secret", + "MUPOT_MCP_TOKEN": "should-not-inherit-from-parent", + "SECRET_MISC": "still-not-copied", + } + child = build_codex_child_env(parent, mcp_env_var="MUPOT_MCP_TOKEN", token="mcp-only-token") + forbidden = [ + "GITHUB_TOKEN", + "GH_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "CLOUDFLARE_API_TOKEN", + "CF_API_TOKEN", + "DEPLOY_KEY", + "WRANGLER_SEND_METRICS", + "NPM_TOKEN", + "NODE_AUTH_TOKEN", + "SECRET_MISC", + ] + leaked = [k for k in forbidden if k in child] + if leaked: + fail("codex child env scrub", f"leaked keys: {leaked}") + if child.get("MUPOT_MCP_TOKEN") != "mcp-only-token": + fail("codex child env scrub", "MCP token not set to the task token") + if child.get("PATH") != "/usr/bin" or child.get("HOME") != "/home/agent": + fail("codex child env scrub", "PATH/HOME missing from allowlist copy") + if set(child.keys()) - {"PATH", "HOME", "LANG", "MUPOT_MCP_TOKEN"}: + # LANG is optional allowlisted; anything else is unexpected here + extra = set(child.keys()) - {"PATH", "HOME", "LANG", "MUPOT_MCP_TOKEN"} + if extra: + fail("codex child env scrub", f"unexpected keys: {sorted(extra)}") + ok("codex child env scrub", f"keys={sorted(child)}") + + +def proof_danger_full_access_disallowed() -> None: + try: + resolve_sandbox("danger-full-access") + fail("danger-full-access disallowed", "resolve_sandbox accepted danger-full-access") + except ValueError: + ok("danger-full-access disallowed") + if resolve_sandbox("workspace-write") != "workspace-write": + fail("danger-full-access disallowed", "workspace-write should remain allowed") + if resolve_sandbox("read-only") != "read-only": + fail("danger-full-access disallowed", "read-only should remain allowed") + + +def proof_detach_inbox_implemented() -> None: + # Canonical domain bytes (contract lock). + detach_msg = canonical_detach_message( + tenant="t", agent_id="a", ts=1, nonce="n" + ).decode("utf-8") + inbox_msg = canonical_inbox_message( + tenant="t", agent_id="a", peek=True, limit=20, ts=1, nonce="n" + ).decode("utf-8") + attach_msg = canonical_attach_message( + tenant="t", + agent_id="a", + agent_type="builder", + runtime="codex", + lifecycle="on_demand", + ts=1, + nonce="n", + ).decode("utf-8") + if not detach_msg.startswith(SIGNED_DETACH_DOMAIN + "\n"): + fail("detach domain implemented", detach_msg) + if not inbox_msg.startswith(SIGNED_INBOX_DOMAIN + "\n"): + fail("inbox domain implemented", inbox_msg) + if not attach_msg.startswith(SIGNED_ATTACH_DOMAIN + "\n"): + fail("attach domain implemented", attach_msg) + + cfg = _cfg(key_path=Path("/tmp/fake.key")) + identity = _identity() + + class _FakeKey: + def sign(self, message: bytes) -> bytes: + return b"\x11" * 64 + + http_calls: list[str] = [] + + def fake_http(method: str, url: str, **_kwargs: Any) -> tuple[int, dict[str, Any]]: + http_calls.append(f"{method} {url}") + return 200, {"ok": True, "messages": []} + + with ( + patch("runtime_adapter_v1.Path.is_file", return_value=True), + patch("runtime_adapter_v1._load_ed25519_private_key", return_value=_FakeKey()), + patch("runtime_adapter_v1._http_json", side_effect=fake_http), + ): + signed_detach(cfg, identity) + signed_inbox(cfg, identity, peek=True, limit=10) + # detach() prefers signed when key exists + detach(cfg, identity) + + # bearer detach path (no key) + cfg_bearer = _cfg(key_path=None) + with patch("runtime_adapter_v1._http_json", side_effect=fake_http): + bearer_detach(cfg_bearer, identity) + + needed = [ + "/api/fleet/detach-signed", + "/api/inbox/signed", + "/api/fleet/detach", + ] + joined = " | ".join(http_calls) + missing = [p for p in needed if p not in joined] + if missing: + fail("detach/inbox call paths", f"missing {missing}; calls={http_calls}") + ok("detach/inbox implemented", f"calls={len(http_calls)}") + + +def main() -> int: + proofs = [ + proof_attach_fail_closed, + proof_signed_attach_bad_sig_path, + proof_child_env_scrub, + proof_danger_full_access_disallowed, + proof_detach_inbox_implemented, + ] + for proof in proofs: + try: + proof() + except SystemExit: + raise + except Exception: # noqa: BLE001 + traceback.print_exc() + fail(proof.__name__, "unhandled exception") + print("") + print(f"runtime-adapter driver behavioral proofs: {len(proofs)}/{len(proofs)} passed") + return 0 + + +if __name__ == "__main__": + # Import helper: keep build_codex_child_env importable without pulling argparse + # side effects from codex-worker at module load. Re-export via thin shim module. + raise SystemExit(main()) diff --git a/scripts/runtime-adapter-driver-conformance.mjs b/scripts/runtime-adapter-driver-conformance.mjs new file mode 100644 index 00000000..d2733262 --- /dev/null +++ b/scripts/runtime-adapter-driver-conformance.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * Offline conformance for topology-A runtime-adapter/v1 drivers + * (cursor-worker.py + mumcp-worker.py + codex-worker.py). + * + * Two layers: + * 1. Structural markers (contract ids, land-at-review rails, runtime types) + * 2. Behavioral proofs via runtime-adapter-driver-conformance-proof.py — + * attach fail-closed, child-env scrub, detach/inbox implemented (not just declared) + * + * npm run conformance:runtime:drivers + */ +import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +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 childEnv = read('scripts/codex_child_env.py') +const attach = read('src/fleet/attach-routes.ts') +const presence = read('src/mcp/presence.ts') +const codexToml = read('connectors/codex/config.toml') + +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 declares fleet-detach:v1', adapter.includes('SIGNED_DETACH_DOMAIN = "fleet-detach:v1"')) +check('adapter declares agent-inbox:v1', adapter.includes('SIGNED_INBOX_DOMAIN = "agent-inbox: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('adapter implements signed + bearer detach', adapter.includes('def signed_detach(') && adapter.includes('def bearer_detach(')) +check('adapter implements signed inbox', adapter.includes('def signed_inbox(') && adapter.includes('def canonical_inbox_message(')) +check( + 'adapter attach is fail-closed (no soft swallow)', + adapter.includes('Attach / signature verification failure is TERMINAL') && + !adapter.includes('attach failed (non-fatal this cycle)') && + !adapter.includes('attach failure must not block task work'), +) +check( + 'adapter does not client-assert presence capabilities', + !adapter.includes('presence_capabilities') && + !/["']capabilities["']\s*:/.test(adapter.split('def register_port1_presence')[1]?.split('def boot_session')[0] ?? ''), +) + +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('cursor-worker does not assert presence capabilities', !cursor.includes('presence_capabilities')) + +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('mumcp-worker does not assert presence capabilities', !mumcp.includes('presence_capabilities')) + +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-worker does not assert presence capabilities', !codex.includes('presence_capabilities')) +check('codex-worker builds allowlisted child env', codex.includes('build_codex_child_env(') && !/env = dict\(os\.environ\)\s*\n\s*env\[CODEX_MCP_ENV_VAR\]/.test(codex)) +check('codex child env disallow danger-full-access', childEnv.includes('danger-full-access') && childEnv.includes('DISALLOWED_SANDBOX')) +check('codex child env allowlist excludes GITHUB_', childEnv.includes('GITHUB_') && childEnv.includes('CODEX_CHILD_ENV_ALLOWLIST')) +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('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( + 'presence capabilities are server-derived', + presence.includes('derivePresenceCapabilities') && + presence.includes('ALWAYS server-derived') && + presence.includes('Client-supplied args.capabilities are ignored'), +) + +const failed = checks.filter((c) => !c.ok) +console.log('') +console.log(`runtime-adapter driver structural checks: ${checks.length - failed.length}/${checks.length} passed (${contract})`) +if (failed.length) { + console.error('FAILED:', failed.map((f) => f.name).join(', ')) + process.exit(1) +} + +console.log('') +console.log('--- behavioral proofs ---') +const proof = spawnSync( + process.env.PYTHON || 'python3', + [path.join(root, 'scripts/runtime-adapter-driver-conformance-proof.py')], + { cwd: root, encoding: 'utf8', env: process.env }, +) +if (proof.stdout) process.stdout.write(proof.stdout) +if (proof.stderr) process.stderr.write(proof.stderr) +if (proof.status !== 0) { + console.error('behavioral proofs FAILED') + process.exit(proof.status ?? 1) +} +console.log('') +console.log(`runtime-adapter driver conformance: structural+behavioral PASSED (${contract})`) diff --git a/scripts/runtime_adapter_v1.py b/scripts/runtime_adapter_v1.py new file mode 100644 index 00000000..96692d16 --- /dev/null +++ b/scripts/runtime_adapter_v1.py @@ -0,0 +1,619 @@ +#!/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 canonical_detach_message(*, tenant: str, agent_id: str, ts: int, nonce: str) -> bytes: + """Byte-identical to src/fleet/signed-detach.ts / fleet-runtime/fleet-sign.mjs.""" + return "\n".join( + [SIGNED_DETACH_DOMAIN, tenant, agent_id, str(ts), nonce] + ).encode("utf-8") + + +def canonical_inbox_message( + *, + tenant: str, + agent_id: str, + peek: bool, + limit: int, + ts: int, + nonce: str, +) -> bytes: + """Byte-identical to src/fleet/signed-inbox.ts / fleet-runtime/fleet-sign.mjs.""" + return "\n".join( + [ + SIGNED_INBOX_DOMAIN, + tenant, + agent_id, + "1" if peek else "0", + str(limit), + str(ts), + nonce, + ] + ).encode("utf-8") + + +def signed_detach( + cfg: AdapterConfig, + identity: RuntimeIdentity, + *, + timeout: float = 30.0, +) -> dict[str, Any]: + """POST /api/fleet/detach-signed with fleet-detach:v1 domain separation.""" + if cfg.key_path is None or not cfg.key_path.is_file(): + raise FileNotFoundError("signed_detach requires AdapterConfig.key_path") + priv = _load_ed25519_private_key(cfg.key_path) + ts = int(time.time()) + nonce = _b64url(secrets.token_bytes(32)) + message = canonical_detach_message( + tenant=identity.tenant, + agent_id=identity.agent_id, + ts=ts, + nonce=nonce, + ) + sig = _b64url(priv.sign(message)) + body: dict[str, Any] = { + "agent_id": identity.agent_id, + "ts": ts, + "nonce": nonce, + "sig": sig, + } + status, payload = _http_json( + "POST", + f"{cfg.api_base_url.rstrip('/')}{DETACH_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"detach-signed failed HTTP {status}: {payload}") + return payload + + +def bearer_detach( + cfg: AdapterConfig, + identity: RuntimeIdentity, + *, + timeout: float = 30.0, +) -> dict[str, Any]: + """POST /api/fleet/detach — token-welded agents without a registered key.""" + body: dict[str, Any] = {"agent_id": identity.agent_id} + status, payload = _http_json( + "POST", + f"{cfg.api_base_url.rstrip('/')}{DETACH_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 detach failed HTTP {status}: {payload}") + return payload + + +def detach(cfg: AdapterConfig, identity: RuntimeIdentity) -> dict[str, Any]: + """Prefer signed detach (fleet-detach:v1) when a host key exists; else bearer.""" + if cfg.key_path is not None and cfg.key_path.is_file(): + return signed_detach(cfg, identity) + return bearer_detach(cfg, identity) + + +def signed_inbox( + cfg: AdapterConfig, + identity: RuntimeIdentity, + *, + peek: bool, + limit: int, + timeout: float = 30.0, +) -> dict[str, Any]: + """POST /api/inbox/signed with agent-inbox:v1 domain separation.""" + if cfg.key_path is None or not cfg.key_path.is_file(): + raise FileNotFoundError("signed_inbox requires AdapterConfig.key_path") + if not isinstance(limit, int) or limit < 1 or limit > 100: + raise ValueError("signed_inbox limit must be an integer 1-100") + priv = _load_ed25519_private_key(cfg.key_path) + ts = int(time.time()) + nonce = _b64url(secrets.token_bytes(32)) + message = canonical_inbox_message( + tenant=identity.tenant, + agent_id=identity.agent_id, + peek=peek, + limit=limit, + ts=ts, + nonce=nonce, + ) + sig = _b64url(priv.sign(message)) + body: dict[str, Any] = { + "agent_id": identity.agent_id, + "peek": peek, + "limit": limit, + "ts": ts, + "nonce": nonce, + "sig": sig, + } + status, payload = _http_json( + "POST", + f"{cfg.api_base_url.rstrip('/')}/api/inbox/signed", + headers={"content-type": "application/json", "User-Agent": cfg.user_agent}, + body=body, + timeout=timeout, + ) + if status >= 400: + raise RuntimeError(f"signed inbox failed HTTP {status}: {payload}") + return payload + + +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, + log: LogFn, +) -> None: + """Best-effort Port-1 self-registration (concierge dispatcher). Non-fatal. + + Capabilities are NEVER client-asserted — the pot derives them server-side + from the bound agent / fleet agent_type. The driver only names its adapter. + """ + try: + mcp_call( + cfg, + "presence_register", + { + "adapter": adapter, + "kind": "agent_system", + "project_id": None, + }, + ) + log(f"presence: registered/refreshed (adapter={adapter}; capabilities server-derived)") + 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, + log: LogFn, + host: str = "", +) -> RuntimeIdentity: + """Resolve identity (server) → attach (fail-closed) → Port-1 presence (soft). + + Attach / signature verification failure is TERMINAL: no presence, no poll, + no claim, no dispatch. Only presence-registration transient errors may be soft. + """ + 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}" + ) + # Fail-closed: every attach failure (including signature verification) is terminal. + 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}" + ) + register_port1_presence(cfg, adapter=presence_adapter, 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/src/mcp/presence.ts b/src/mcp/presence.ts index 17b0650e..c282a898 100644 --- a/src/mcp/presence.ts +++ b/src/mcp/presence.ts @@ -12,6 +12,9 @@ // an attacker cannot even ATTEMPT to name another principal (schema-level, not // just a runtime check — additionalProperties:false rejects an extra field // before the handler runs). +// - presence_register capabilities are ALWAYS server-derived from the bound +// agent's fleet_agents.agent_type (builder→build). Client-supplied +// args.capabilities are ignored — never runtime-asserted routing data. // - presence_register / presence_heartbeat ALSO bind that identity into a // project's roster when project_id is non-null — that is a WRITE into project- // scoped state, so it is gated through the SAME project-visibility primitive @@ -34,7 +37,7 @@ // than "this project's roster," so it fails closed to the org floor instead of // silently granting it to every observer. -import type { AuthContext } from '../types' +import type { AuthContext, Env } from '../types' import { registerModule, heartbeatModule, @@ -51,6 +54,28 @@ const NULLABLE_STRING_SCHEMA = { type: ['string', 'null'] } const OPTIONAL_STRING_ARRAY_SCHEMA = { type: 'array', items: { type: 'string' } } const MODULE_KIND_ENUM = ['agent_system', 'workflow', 'surface'] +/** Map fleet agent_type → Port-1 presence capability tags. Server-derived only — + * the client must never assert these (concierge routes on `build`). */ +const FLEET_TYPE_PRESENCE_CAPS: Readonly> = { + builder: ['build'], + reviewer: ['review'], +} + +/** + * Derive Port-1 presence capabilities from the bound agent's fleet agent_type. + * Client-supplied capabilities are ignored — capability is ALWAYS server-derived. + */ +async function derivePresenceCapabilities(env: Env, identity: string): Promise { + const row = await env.DB.prepare( + `SELECT agent_type FROM fleet_agents WHERE tenant = ?1 AND agent_id = ?2 LIMIT 1`, + ) + .bind(env.TENANT_SLUG, identity) + .first<{ agent_type: string | null }>() + if (!row || typeof row.agent_type !== 'string') return [] + const caps = FLEET_TYPE_PRESENCE_CAPS[row.agent_type] + return caps ? [...caps] : [] +} + // The caller's own identity, server-derived — never taken from args (see file // docstring). A welded agent-scoped token IS that agent; a plain member/operator // token registers as itself. Mirrors memberActor()/resolveTaskAssignee's own-identity @@ -108,13 +133,10 @@ const toolPresenceRegister: ToolSpec = { if (!project) return fail(404, 'project_not_found') } - let capabilities: string[] | undefined - if (args.capabilities !== undefined) { - if (!Array.isArray(args.capabilities) || !args.capabilities.every((v) => typeof v === 'string')) { - return fail(400, 'invalid_args', 'capabilities must be a string[]') - } - capabilities = args.capabilities - } + // Capabilities are ALWAYS server-derived from the bound agent's fleet + // agent_type. Client-supplied args.capabilities are ignored (authorization- + // relevant routing data must never be runtime-asserted). + const capabilities = await derivePresenceCapabilities(env, identity) const result = await registerModule(env, { identity, diff --git a/tests/mcp-presence-tools.test.ts b/tests/mcp-presence-tools.test.ts index 297c867a..d4ae0d20 100644 --- a/tests/mcp-presence-tools.test.ts +++ b/tests/mcp-presence-tools.test.ts @@ -126,6 +126,49 @@ describe('presence_register — identity is server-derived, never from args', () expect(rows).toHaveLength(1) expect(rows[0].adapter).toBe('cursor') }) + + it('ignores client-asserted capabilities and derives build from fleet agent_type', async () => { + const db = makeDb() + await db.env.DB.prepare( + `INSERT INTO fleet_agents + (agent_id, tenant, display, runtime, squads, lifecycle, status, reported_by, agent_type, member_id) + VALUES (?1, ?2, '', 'codex', '[]', 'on_demand', 'running', ?1, 'builder', ?3)`, + ) + .bind('agent-welded-1', TENANT, 'member-welded') + .run() + // Attacker tries to claim build+admin via args — must be ignored. + const outcome = await invokeTool( + weldedAgent, + db.env, + 'presence_register', + { adapter: 'codex', project_id: 'proj-a', capabilities: ['build', 'admin', 'deploy'] }, + ORIGIN, + ) + expect(outcome.ok).toBe(true) + if (outcome.ok) { + const result = outcome.result as { module: { capabilities: string[] } } + expect(result.module.capabilities).toEqual(['build']) + expect(result.module.capabilities).not.toContain('admin') + expect(result.module.capabilities).not.toContain('deploy') + } + }) + + it('stores empty capabilities when no fleet agent_type is known (no client assert)', async () => { + const db = makeDb() + const outcome = await invokeTool( + squadObserver, + db.env, + 'presence_register', + { adapter: 'claude_code', project_id: 'proj-a', capabilities: ['build'] }, + ORIGIN, + ) + expect(outcome.ok).toBe(true) + if (outcome.ok) { + const result = outcome.result as { module: { capabilities: string[] } } + // Client asserted build, but no fleet row → server stores []. + expect(result.module.capabilities).toEqual([]) + } + }) }) describe('presence_register / presence_heartbeat — project-write authz (P1 fix, 2026-07-21)', () => { 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..5cf89d5e --- /dev/null +++ b/tests/runtime-adapter-drivers.test.ts @@ -0,0 +1,158 @@ +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 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') + +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 signed_detach(') + expect(adapter).toContain('def bearer_detach(') + expect(adapter).toContain('def signed_inbox(') + 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"') + // P0-1: attach failure is TERMINAL (fail-closed), never soft-swallowed. + expect(adapter).toContain('Attach / signature verification failure is TERMINAL') + expect(adapter).not.toContain('attach failed (non-fatal this cycle)') + expect(adapter).not.toContain('attach failure must not block task work') + // P0-2: presence capabilities are not client-asserted. + expect(adapter).not.toContain('presence_capabilities') + }) + + 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') + // P0-2 / P0-3 rails + expect(codexWorker).not.toContain('presence_capabilities') + expect(codexWorker).toContain('build_codex_child_env(') + expect(codexWorker).toContain('resolve_sandbox(') + }) + + it('scrubs the codex exec child env and disallows danger-full-access (P0-3)', () => { + const childEnv = read('scripts/codex_child_env.py') + expect(childEnv).toContain('CODEX_CHILD_ENV_ALLOWLIST') + expect(childEnv).toContain('"PATH"') + expect(childEnv).toContain('"HOME"') + expect(childEnv).toContain('GITHUB_') + expect(childEnv).toContain('DISALLOWED_SANDBOX') + expect(childEnv).toContain('danger-full-access') + expect(childEnv).toContain('def build_codex_child_env(') + expect(childEnv).toContain('def resolve_sandbox(') + // Driver must not copy full os.environ into the codex child. + expect(codexWorker).not.toMatch(/env = dict\(os\.environ\)\s*\n\s*env\[CODEX_MCP_ENV_VAR\]/) + }) + + it('presence_register ignores client capabilities (P0-2 server-derived)', () => { + const presence = read('src/mcp/presence.ts') + expect(presence).toContain('derivePresenceCapabilities') + expect(presence).toContain('Client-supplied args.capabilities are ignored') + expect(presence).toContain("builder: ['build']") + }) + + 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('accepts cursor and codex 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(contractMd).toContain('`cursor`') + expect(contractMd).toContain('`codex`') + }) + + 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(adapter).toContain('REFERENCE adapter') + }) +})