diff --git a/.egg-state/agent-outputs/coder/slice-3-seam-table-for-documenter.md b/.egg-state/agent-outputs/coder/slice-3-seam-table-for-documenter.md new file mode 100644 index 0000000000..8ac51e5f2b --- /dev/null +++ b/.egg-state/agent-outputs/coder/slice-3-seam-table-for-documenter.md @@ -0,0 +1,60 @@ +# slice-3 seam-table handoff — `gateway/gateway/` decomposition (#3312, slice-18-equivalent) + +For the documenter's `gateway/CLAUDE.md` "Decomposition seams" subsection. This +documents the **landed** split (coder-owned code; CLAUDE.md is documenter-owned). + +## Summary + +`gateway/gateway.py` (10,648 lines / 419 KB — structural outlier, over the byte +cap) → `gateway/gateway/` sub-package (barrel + 14 submodules). Pure refactor: +handler/helper bodies are AST-identical to the pre-split file. Flask +`@app.route` decorators stay on **thin wrappers in the barrel** +(`__init__.py`); the wrapper bodies delegate to the implementation in the +`_` submodule (the routes-handling convention). The barrel does +explicit per-symbol re-exports + declares `__all__`, so external importers and +`patch("gateway.gateway.")` / `patch.object(gateway, "")` targets +resolve unchanged. `gateway.py`'s allowlist entry is removed (the file-size +`files:` map now holds only `orchestrator/routes/pipelines.py`, slice-4's target). + +## Submodule layout (all under both caps: ≤1500 lines / ≤100 KB) + +| Submodule | Lines | Responsibility | +|-----------|-------|----------------| +| `__init__.py` (barrel) | 1342 | Flask `app`, all 49 `@app.route` thin wrappers, unhandled-exception handler, launcher-secret auth + `require_*` decorators, mount-path translation (`gateway.open` seam), `get_worktree_manager`/`get_anthropic_client`/`get_launcher_secret` seam getters, per-symbol re-exports + `__all__` | +| `__main__.py` | 15 | `python3 -m gateway` container entry point → `main()` | +| `_helpers.py` | 187 | Response builders (`make_response`/`make_error`/`make_success`/`make_worktree_not_found_error`), `audit_log`, orchestrator/squid connectivity checks, commit-observer lookup | +| `_health.py` | 253 | `/api/v1/health`, `/config/reload`, `/proxy/ca-cert` handlers + `_reload_all_config` | +| `_git_ops.py` | 1357 | `/api/v1/git/push` + `/git/fetch` implementations + detached-head hint | +| `_git_execute.py` | 800 | `/api/v1/git/execute` implementation | +| `_gh_ops.py` | 1083 | PR create/comment/edit/close, find-open-pr, list-open-prs, merge-state, ready + `_apply_pr_labels` | +| `_gh_execute.py` | 763 | `/api/v1/gh/execute` implementation | +| `_jira.py` | 847 | Jira read routes (ticket get/search/comments/remotelinks/transition/execute) + error/context helpers | +| `_jira_writes.py` | 964 | Jira write routes (ticket create/edit/comment-add, issue-link) + write validators | +| `_confluence.py` | 1230 | Confluence routes (page get/descendants/comments, space pages/list, search, execute) + space-allowlist helpers | +| `_worktree.py` | 741 | Worktree create/delete/list/prune + container-path mapping + cleanup helpers | +| `_sessions.py` | 943 | Session create/delete/heartbeat/get/update/phase, repos-visibility, sessions-list | +| `_proxy.py` | 946 | `/v1/messages` + `/v1/messages/count_tokens` Anthropic proxy, credential injection, hop/streaming logic | +| `_server.py` | 413 | `main()` + `_run_health_server` (container startup / graceful shutdown) | + +## Seam-preservation notes (for the pattern narrative) + +- **Barrel re-export**: every moved non-route symbol is re-exported from the + barrel, plus an `__all__` declaring the public re-export surface; the barrel + keeps the full gateway-sibling import block so `gateway.gateway.` + patch targets survive. +- **`_b()` accessor**: submodules resolve *patched* seam getters/validators + (e.g. `get_session_manager`, `validate_repo_path`, `get_github_client`) on the + barrel at call time via a small `_b()` helper, so `patch("gateway.gateway.X")` + stays effective after the split. Non-patched cross-submodule helpers use + direct typed imports (keeps mypy strict happy). +- **`_BarrelLogger` proxy**: submodule `logger` proxies to the barrel's logger + so tests patching `gateway.logger` observe submodule log calls. +- **Module-singleton seams** (`gateway.subprocess.run`, `gateway.time.sleep`, + `gateway.open`): kept as barrel attributes; patching the shared stdlib module + is honoured process-wide. +- **Container packaging** (same slice): `gateway/Dockerfile` gains + `COPY gateway/gateway/ ./gateway/`; the Flask launch moves from + `python3 gateway.py` to `python3 -m gateway` (`__main__.py` → `main()`) in + `gateway/entrypoint.sh`. NOTE: the in-image `python -c 'import gateway'` / + serve-on-9848 smoke check could not run in the sandbox (no docker); the + COPY/launch follow the git_client/worktree_manager slice-11/12 pattern. diff --git a/.egg-state/brc-history/issue-3312-v2-implement-slice-3.json b/.egg-state/brc-history/issue-3312-v2-implement-slice-3.json new file mode 100644 index 0000000000..5f506aa3a5 --- /dev/null +++ b/.egg-state/brc-history/issue-3312-v2-implement-slice-3.json @@ -0,0 +1,6101 @@ +[ + { + "id": "c9c0bebe-465e-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:26:34.741290+00:00", + "phase": "implement" + }, + { + "id": "ff582b95-ff52-44", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:26:35.772627+00:00", + "phase": "implement" + }, + { + "id": "b787586b-f7cf-48", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:26:41.136524+00:00", + "phase": "implement" + }, + { + "id": "4e5d29b9-60ec-42", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:26:42.726017+00:00", + "phase": "implement" + }, + { + "id": "4ac0425c-d34a-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:28:53.660301+00:00", + "phase": "implement" + }, + { + "id": "a63094a6-6f3a-48", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "documenter task-3-5 (gateway/CLAUDE.md seam-table rows) blocks on the coder's gateway/gateway.py decomposition landing first \u2014 origin/slice-3 is still at integration base, gateway/gateway/ dir absent. Cannot author an accurate seam table (real module names + line counts) until the coder ships. Not proposing fabricated content.", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "coder", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:28:55.072890+00:00", + "phase": "implement" + }, + { + "id": "04b36acc-e572-44", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=81547 util=0.08 cache_hit=0.95 decision=no_warm_session", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:29:07.279856+00:00", + "phase": "implement" + }, + { + "id": "0ed9840c-3622-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:29:10.818513+00:00", + "phase": "implement" + }, + { + "id": "3d8409ea-9480-46", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:29:14.318507+00:00", + "phase": "implement" + }, + { + "id": "f0108d84-0fde-45", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:29:20.034266+00:00", + "phase": "implement" + }, + { + "id": "f7ffafbf-b316-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "Still blocked: documenter task-3-5 (gateway/CLAUDE.md seam table) needs the coder's gateway/gateway.py decomposition first. origin/slice-3 unchanged at integration base; gateway/gateway/ dir absent; no coder handoff artifact yet. Coder still WORKING. Will author + propose the seam subsection once the decomposition lands.", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "coder", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:29:32.983827+00:00", + "phase": "implement" + }, + { + "id": "9e50db1d-eef1-42", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=83767 util=0.08 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:29:37.988778+00:00", + "phase": "implement" + }, + { + "id": "8542331d-998f-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:29:45.118405+00:00", + "phase": "implement" + }, + { + "id": "46056cc1-f8fc-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:29:49.709242+00:00", + "phase": "implement" + }, + { + "id": "0b60cbda-70a5-46", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "Unchanged \u2014 still blocked on coder's gateway/gateway.py decomposition. origin/slice-3 at integration base; no gateway/gateway/ package; no coder handoff artifact. documenter task-3-5 (gateway/CLAUDE.md seam table) will be authored + proposed as soon as the decomposition lands.", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "coder", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:30:02.280734+00:00", + "phase": "implement" + }, + { + "id": "1a0091cd-467c-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=86705 util=0.09 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:30:06.053722+00:00", + "phase": "implement" + }, + { + "id": "84cbbbd7-8d40-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:31:58.342631+00:00", + "phase": "implement" + }, + { + "id": "a0f21b8e-3548-46", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:34:11.951515+00:00", + "phase": "implement" + }, + { + "id": "ba3700e2-905f-46", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:36:39.873324+00:00", + "phase": "implement" + }, + { + "id": "533170e0-5498-41", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:39:30.261894+00:00", + "phase": "implement" + }, + { + "id": "33941347-6add-49", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:41:43.293827+00:00", + "phase": "implement" + }, + { + "id": "447a57a9-6669-49", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:43:50.089597+00:00", + "phase": "implement" + }, + { + "id": "10094515-6551-44", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:46:08.893722+00:00", + "phase": "implement" + }, + { + "id": "a9439116-7d79-48", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:49:28.166644+00:00", + "phase": "implement" + }, + { + "id": "3ef0f26b-326c-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:52:34.501627+00:00", + "phase": "implement" + }, + { + "id": "492377c5-e55f-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:54:52.223330+00:00", + "phase": "implement" + }, + { + "id": "1c6b4cd9-001c-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T02:58:01.708580+00:00", + "phase": "implement" + }, + { + "id": "9560829a-9031-49", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:00:16.297044+00:00", + "phase": "implement" + }, + { + "id": "c826029b-a978-46", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:00:20.736425+00:00", + "phase": "implement" + }, + { + "id": "9b58fd86-0bf7-49", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "Unchanged \u2014 blocked on coder's gateway/gateway.py (10.6k-line) decomposition; large refactor, coder still WORKING. origin/slice-3 at base, no gateway/gateway/ package, no handoff artifact. documenter task-3-5 seam table ready to author + propose the moment it lands.", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "coder", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:00:38.673978+00:00", + "phase": "implement" + }, + { + "id": "ec0f8c81-eef2-42", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=89756 util=0.09 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:00:42.880563+00:00", + "phase": "implement" + }, + { + "id": "71a2eac6-e2cf-47", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:00:50.048654+00:00", + "phase": "implement" + }, + { + "id": "f55bf75b-4a24-45", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:03:03.085842+00:00", + "phase": "implement" + }, + { + "id": "c30c2080-2c86-47", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:05:12.763805+00:00", + "phase": "implement" + }, + { + "id": "986c8156-3fec-48", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:07:23.620395+00:00", + "phase": "implement" + }, + { + "id": "b9663762-20ac-49", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:09:29.175800+00:00", + "phase": "implement" + }, + { + "id": "8354b7d5-2301-40", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:13:35.026695+00:00", + "phase": "implement" + }, + { + "id": "06d4aa5a-d6bd-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:15:01.067923+00:00", + "phase": "implement" + }, + { + "id": "a8a0d164-ef3e-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:15:05.768455+00:00", + "phase": "implement" + }, + { + "id": "5121127d-8ec3-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "Blocked on coder's gateway/gateway.py decomposition; coder healthy (regular liveness heartbeats), actively working the 10.6k-line refactor. origin/slice-3 still at base, no gateway/gateway/ package yet. documenter task-3-5 seam table ready to author + propose on landing.", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "coder", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:15:30.443881+00:00", + "phase": "implement" + }, + { + "id": "80abe56f-3830-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=94301 util=0.09 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:15:35.060696+00:00", + "phase": "implement" + }, + { + "id": "dc0549c0-e041-42", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:15:36.245556+00:00", + "phase": "implement" + }, + { + "id": "ae5d06ca-6ee0-42", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:15:44.286294+00:00", + "phase": "implement" + }, + { + "id": "9105b603-fd0b-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:15:48.914391+00:00", + "phase": "implement" + }, + { + "id": "021e874f-4f5f-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "Blocked on coder's gateway/gateway.py decomposition; coder alive (liveness heartbeats through 03:09, no failure), actively working the 10.6k-line refactor. origin/slice-3 still at base, no gateway/gateway/ package. documenter task-3-5 seam table ready to author + propose on landing.", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "coder", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:16:07.985833+00:00", + "phase": "implement" + }, + { + "id": "9884d86e-254a-42", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=98373 util=0.10 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:16:12.439507+00:00", + "phase": "implement" + }, + { + "id": "7b9b1e0e-17dc-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:16:21.285168+00:00", + "phase": "implement" + }, + { + "id": "5e96bfdb-b248-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:16:25.734001+00:00", + "phase": "implement" + }, + { + "id": "5ee06f1a-92fe-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "Blocked on coder's gateway/gateway.py decomposition; coder alive (heartbeat 03:15:36), no proposal/failure yet. origin/slice-3 at base, no gateway/gateway/ package. documenter task-3-5 seam table ready to author + propose on landing.", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "coder", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:16:48.910691+00:00", + "phase": "implement" + }, + { + "id": "d3802f8f-d743-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=102065 util=0.10 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:16:53.130809+00:00", + "phase": "implement" + }, + { + "id": "28e92383-02b8-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:17:37.587927+00:00", + "phase": "implement" + }, + { + "id": "48c45268-d0c5-43", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:19:51.870379+00:00", + "phase": "implement" + }, + { + "id": "53871e17-2e53-46", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:22:45.198452+00:00", + "phase": "implement" + }, + { + "id": "beef2875-5c6b-41", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:24:52.848162+00:00", + "phase": "implement" + }, + { + "id": "6400332f-2399-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:27:06.450363+00:00", + "phase": "implement" + }, + { + "id": "55d97f33-07a2-47", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:29:16.904531+00:00", + "phase": "implement" + }, + { + "id": "a6441cfa-d72a-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:31:22.814413+00:00", + "phase": "implement" + }, + { + "id": "8f21be74-a805-40", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:34:53.470355+00:00", + "phase": "implement" + }, + { + "id": "24e0d53f-c1af-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:37:08.291381+00:00", + "phase": "implement" + }, + { + "id": "28120f2a-9a88-40", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:39:26.485729+00:00", + "phase": "implement" + }, + { + "id": "f81345db-780d-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:41:36.120198+00:00", + "phase": "implement" + }, + { + "id": "e6240bb4-cf48-46", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:44:16.476877+00:00", + "phase": "implement" + }, + { + "id": "c3c1b7fd-5571-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:46:18.502570+00:00", + "phase": "implement" + }, + { + "id": "3eb0bc92-2336-46", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:47:06.635065+00:00", + "phase": "implement" + }, + { + "id": "1d0da9ef-0bd6-40", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:47:11.045259+00:00", + "phase": "implement" + }, + { + "id": "1095d4d6-7d81-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "Unchanged \u2014 blocked on coder's gateway/gateway.py decomposition (not yet on origin/slice-3, no gateway/gateway/ package). documenter task-3-5 seam table ready to author + propose on landing.", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "coder", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:47:20.596751+00:00", + "phase": "implement" + }, + { + "id": "6cc84f80-fb3c-42", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=104549 util=0.10 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:47:24.302076+00:00", + "phase": "implement" + }, + { + "id": "f52be8b1-e0ee-47", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:49:33.824155+00:00", + "phase": "implement" + }, + { + "id": "7d629d53-8ba7-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from coder", + "body": "Decompose gateway/gateway.py (10,648 lines / 419KB, over byte cap) into a gateway/gateway/ sub-package: barrel (1342 lines) + 14 submodules, all under 1500-line/100KB caps. Two bisectable commits: 9079e9c02 pure git-mv baseline (package import/path fixups + conftest spec-load + Dockerfile COPY + __main__.py / `python3 -m gateway` launch), 3a85636d6 the cluster extraction. Pure refactor \u2014 bodies AST-identical. Routes convention: @app.route decorators stay on thin barrel wrappers delegating to _ impls; barrel re-exports every symbol + declares __all__ so patch(\"gateway.gateway.X\")/patch.object(gateway,\"X\")/monkeypatch.setattr targets resolve unchanged. Seams resolved on the barrel at call time via _b(); _BarrelLogger proxy forwards gateway.logger; module-singleton seams (subprocess.run/time.sleep/open) stay barrel attrs. Allowlist entry dropped (files: map now only pipelines.py). Verification: gateway/tests 3342 passed (7 failed + 6 errors all pre-existing sandbox-env, identical to baseline by stash+rerun); tests/gateway/test_anthropic_proxy.py 65 passed; ruff clean; ratchet exit 0; mypy 1198->56. Image smoke-check not runnable (no docker). CLAUDE.md seam table handed to documenter.", + "metadata": { + "payload": { + "summary": "Decompose gateway/gateway.py (10,648 lines / 419KB, over byte cap) into a gateway/gateway/ sub-package: barrel (1342 lines) + 14 submodules, all under 1500-line/100KB caps. Two bisectable commits: 9079e9c02 pure git-mv baseline (package import/path fixups + conftest spec-load + Dockerfile COPY + __main__.py / `python3 -m gateway` launch), 3a85636d6 the cluster extraction. Pure refactor \u2014 bodies AST-identical. Routes convention: @app.route decorators stay on thin barrel wrappers delegating to _ impls; barrel re-exports every symbol + declares __all__ so patch(\"gateway.gateway.X\")/patch.object(gateway,\"X\")/monkeypatch.setattr targets resolve unchanged. Seams resolved on the barrel at call time via _b(); _BarrelLogger proxy forwards gateway.logger; module-singleton seams (subprocess.run/time.sleep/open) stay barrel attrs. Allowlist entry dropped (files: map now only pipelines.py). Verification: gateway/tests 3342 passed (7 failed + 6 errors all pre-existing sandbox-env, identical to baseline by stash+rerun); tests/gateway/test_anthropic_proxy.py 65 passed; ruff clean; ratchet exit 0; mypy 1198->56. Image smoke-check not runnable (no docker). CLAUDE.md seam table handed to documenter.", + "attestation": { + "commit_shas": [ + "9079e9c02b17a74a9c711265b3a5347f15f101ca", + "3a85636d683bdf48308bc3b35db1823e3e76a6e0" + ], + "files_changed": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "tests_run": 3407, + "checks_passed": [ + "ruff-check", + "ruff-format", + "file-size-ratchet", + "gateway-tests" + ], + "tests_execution_blocked": false + }, + "artifacts": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "risk_considered": "Load-bearing risk is the ~40 mock-patch seams across ~35 referencing files; preserved via barrel re-export + _b() call-time resolution + scanned-from-tests patched set + _BarrelLogger proxy + module-singleton attrs kept in barrel. R3 container packaging handled in-slice; image smoke-check not runnable without docker.", + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "files_changed": [ + "gateway/gateway/__init__.py", + "gateway/gateway/__main__.py", + "gateway/gateway/_helpers.py", + "gateway/gateway/_health.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_git_execute.py", + "gateway/gateway/_gh_ops.py", + "gateway/gateway/_gh_execute.py", + "gateway/gateway/_jira.py", + "gateway/gateway/_jira_writes.py", + "gateway/gateway/_confluence.py", + "gateway/gateway/_worktree.py", + "gateway/gateway/_sessions.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "tests_run": [], + "tasks_satisfied": [ + "task-3-1", + "task-3-2", + "task-3-3", + "task-3-4", + "task-3-5", + "task-3-6", + "task-3-7" + ], + "no_changes_needed": false, + "no_changes_reason": "" + }, + "version": 1, + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:29.966157+00:00", + "phase": "implement" + }, + { + "id": "3bc56adb-e182-48", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:36.073708+00:00", + "phase": "implement" + }, + { + "id": "ef18e02c-e375-40", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:36.124145+00:00", + "phase": "implement" + }, + { + "id": "7e2c2862-47df-41", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:37.631519+00:00", + "phase": "implement" + }, + { + "id": "8c120645-a9cd-45", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:39.017590+00:00", + "phase": "implement" + }, + { + "id": "8cd750b9-1c6c-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:40.568316+00:00", + "phase": "implement" + }, + { + "id": "9c0e90ef-a370-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:42.177809+00:00", + "phase": "implement" + }, + { + "id": "dce8a945-3d5b-46", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:42.645097+00:00", + "phase": "implement" + }, + { + "id": "ec2aeb92-de92-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:43.397145+00:00", + "phase": "implement" + }, + { + "id": "31f7ba47-85ce-46", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:44.498776+00:00", + "phase": "implement" + }, + { + "id": "4a9aa4c7-d794-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:47.354721+00:00", + "phase": "implement" + }, + { + "id": "c20c5330-7733-49", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:49.566715+00:00", + "phase": "implement" + }, + { + "id": "08883db4-39fd-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:50.051660+00:00", + "phase": "implement" + }, + { + "id": "13cb691e-8856-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:51.143272+00:00", + "phase": "implement" + }, + { + "id": "5530529a-4bee-45", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:50:51.827588+00:00", + "phase": "implement" + }, + { + "id": "64c84bd7-337b-43", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=508955 util=0.51 cache_hit=1.00 decision=no_warm_session", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:51:07.638022+00:00", + "phase": "implement" + }, + { + "id": "1565c8cb-a3dc-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_concurrency for coder", + "body": "Concurrency review of slice-3 (gateway.py -> gateway/ sub-package split). Pure refactor, bodies AST-identical. Verified the sole lock invariant empirically: `_worktree_prune_lock` (threading.Lock, defined in _worktree.py, re-exported by barrel) is a SINGLE shared instance \u2014 `gateway._worktree_prune_lock is gateway._worktree._worktree_prune_lock` -> True, and `_b()._worktree_prune_lock` resolves to that same object, so the prune-endpoint mutual exclusion is preserved. acquire(timeout=60) -> 409-on-contention with `.release()` in a `finally` (no lock leak). Both _server.py threads (startup-worktree-cleanup, health-check-server) remain daemon=True fire-and-forget; background session pruner and waitress threads=args.threads unchanged. Module-singleton seams (subprocess/time) stay barrel attributes with no per-submodule duplication. No concurrency regression.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_worktree.py", + "gateway/gateway/_server.py" + ], + "reason": "Concurrency review of slice-3 (gateway.py -> gateway/ sub-package split). Pure refactor, bodies AST-identical. Verified the sole lock invariant empirically: `_worktree_prune_lock` (threading.Lock, defined in _worktree.py, re-exported by barrel) is a SINGLE shared instance \u2014 `gateway._worktree_prune_lock is gateway._worktree._worktree_prune_lock` -> True, and `_b()._worktree_prune_lock` resolves to that same object, so the prune-endpoint mutual exclusion is preserved. acquire(timeout=60) -> 409-on-contention with `.release()` in a `finally` (no lock leak). Both _server.py threads (startup-worktree-cleanup, health-check-server) remain daemon=True fire-and-forget; background session pruner and waitress threads=args.threads unchanged. Module-singleton seams (subprocess/time) stay barrel attributes with no per-submodule duplication. No concurrency regression.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:52:10.218905+00:00", + "phase": "implement" + }, + { + "id": "b7a0c86b-7856-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=68789 util=0.07 cache_hit=0.97 decision=no_warm_session", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:52:17.225620+00:00", + "phase": "implement" + }, + { + "id": "6cbe6d34-0cf5-43", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:52:48.172905+00:00", + "phase": "implement" + }, + { + "id": "43361bad-1b84-48", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:52:48.645200+00:00", + "phase": "implement" + }, + { + "id": "4021cd25-8121-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:52:54.640986+00:00", + "phase": "implement" + }, + { + "id": "953e64a2-144c-45", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:52:56.374183+00:00", + "phase": "implement" + }, + { + "id": "bbb0a4cf-7cb0-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:52:57.294647+00:00", + "phase": "implement" + }, + { + "id": "3b0c6ad3-d84a-43", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:53:01.224770+00:00", + "phase": "implement" + }, + { + "id": "896fb23c-4c21-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "Coder proposed slice-3 (tip 3a85636d6); documenter deliverable (gateway/CLAUDE.md seam table for gateway/gateway/ = barrel+14 submodules) is unblocked and ready to author. BLOCKED ONLY on infra: agent worktree filesystem is currently unmounted (git dir reachable, working tree empty/root-owned, all paths stamped 03:50 = reprovisioning). No fabrication. Will author + propose the moment the worktree remounts.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:54:33.519991+00:00", + "phase": "implement" + }, + { + "id": "2de753a3-c8a4-45", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:54:55.638147+00:00", + "phase": "implement" + }, + { + "id": "8b0bb1bb-09e5-44", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=133235 util=0.13 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:54:58.862991+00:00", + "phase": "implement" + }, + { + "id": "49d03338-0fe1-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:04.068296+00:00", + "phase": "implement" + }, + { + "id": "df1fb7a4-d0b1-46", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:04.360107+00:00", + "phase": "implement" + }, + { + "id": "f8bc7832-18d0-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from tester", + "body": "Tester verification of slice-3 (gateway/gateway.py \u2192 gateway/gateway/ sub-package) at coder commit 3a85636d6. Boundary GREEN, pure refactor confirmed. Import parity: `import gateway.gateway` resolves, Flask `app` present, 61 routes registered identically. LINT: ruff check + ruff format clean on gateway/; file-size ratchet exit 0 with allowlist `files:` map now holding ONLY orchestrator/routes/pipelines.py (gateway.py entry dropped); largest submodule _git_ops.py 1357 lines/57.9KB \u2014 every module under 1500-line/100KB caps. SECURITY: bandit -ll (medium+high) on gateway/gateway \u2192 No issues identified (exit 0). TEST: gateway/tests/ + tests/gateway/ \u2192 3502 passed, 16 skipped, 7 failed, 6 errors. All 7 failures + 6 errors PROVEN sandbox-environmental, not refactor-induced: 3 TestHealthCheckServer fail on OSError [Errno 98] Address already in use (port bind); git_client_base_branch + worktree_manager failures root-cause to \"ERROR: git init is not supported in the container\" (sandbox blocks git init/worktree add). Proof of pre-existing: refactor diff 6be7c255d..3a85636d6 touched NEITHER gateway/git_client/ NOR gateway/worktree_manager/ source (byte-identical) \u2014 their failures cannot originate from the decomposition. Matches coder attestation exactly. No test-patch rewrites needed: tests resolve against the new barrel re-exports; patch seams intact. Tester produced no file changes \u2014 deliverable is the verification verdict.", + "metadata": { + "payload": { + "summary": "Tester verification of slice-3 (gateway/gateway.py \u2192 gateway/gateway/ sub-package) at coder commit 3a85636d6. Boundary GREEN, pure refactor confirmed. Import parity: `import gateway.gateway` resolves, Flask `app` present, 61 routes registered identically. LINT: ruff check + ruff format clean on gateway/; file-size ratchet exit 0 with allowlist `files:` map now holding ONLY orchestrator/routes/pipelines.py (gateway.py entry dropped); largest submodule _git_ops.py 1357 lines/57.9KB \u2014 every module under 1500-line/100KB caps. SECURITY: bandit -ll (medium+high) on gateway/gateway \u2192 No issues identified (exit 0). TEST: gateway/tests/ + tests/gateway/ \u2192 3502 passed, 16 skipped, 7 failed, 6 errors. All 7 failures + 6 errors PROVEN sandbox-environmental, not refactor-induced: 3 TestHealthCheckServer fail on OSError [Errno 98] Address already in use (port bind); git_client_base_branch + worktree_manager failures root-cause to \"ERROR: git init is not supported in the container\" (sandbox blocks git init/worktree add). Proof of pre-existing: refactor diff 6be7c255d..3a85636d6 touched NEITHER gateway/git_client/ NOR gateway/worktree_manager/ source (byte-identical) \u2014 their failures cannot originate from the decomposition. Matches coder attestation exactly. No test-patch rewrites needed: tests resolve against the new barrel re-exports; patch seams intact. Tester produced no file changes \u2014 deliverable is the verification verdict.", + "attestation": { + "tests_run": 3509, + "tests_execution_blocked": false, + "checks_passed": [ + "lint", + "security", + "test" + ], + "check_detail": { + "lint": "ruff check + ruff format clean; check-file-sizes.py exit 0 (allowlist=pipelines.py only)", + "security": "bandit -ll on gateway/gateway: No issues identified", + "test": "gateway/tests/+tests/gateway/: 3502 passed, 16 skipped; 7 failed + 6 errors all sandbox-env (git-init blocked / port-in-use), source byte-identical to baseline" + }, + "verified_commit": "3a85636d683bdf48308bc3b35db1823e3e76a6e0" + }, + "artifacts": [ + ".egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md", + "gateway/gateway/__init__.py", + "scripts/file-size-allowlist.yaml", + "3a85636d683bdf48308bc3b35db1823e3e76a6e0" + ], + "risk_considered": "Pure-refactor risk is import/patch-seam breakage and container-packaging drift. Verified independently: package import + 61-route registration parity; ratchet green with only pipelines.py remaining; the two failing test dirs (git_client, worktree_manager) are byte-identical to baseline so their env failures (git-init blocked in container) are pre-existing. make lint/test-all not runnable via `make` (venv re-sync needs pip egress, blocked \u2192 cert error); ran the underlying tools directly from the working .venv (ruff, bandit, pytest, check-file-sizes). Docker image smoke-check not runnable (no docker) \u2014 same environmental limit the coder noted.", + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "files_changed": [], + "tests_run": [], + "tasks_satisfied": [ + "task-3-7" + ], + "no_changes_needed": false, + "no_changes_reason": "" + }, + "version": 1, + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:07.387316+00:00", + "phase": "implement" + }, + { + "id": "70fa1a9d-bb90-49", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:07.851265+00:00", + "phase": "implement" + }, + { + "id": "2307a381-afe0-47", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:10.978056+00:00", + "phase": "implement" + }, + { + "id": "1cd1771c-13cd-42", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:11.500601+00:00", + "phase": "implement" + }, + { + "id": "7debe930-80a4-44", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:11.614175+00:00", + "phase": "implement" + }, + { + "id": "ab8e8d8b-2fa9-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:11.890473+00:00", + "phase": "implement" + }, + { + "id": "2ad15859-d774-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:11.974041+00:00", + "phase": "implement" + }, + { + "id": "ab89ddef-49eb-43", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:12.997829+00:00", + "phase": "implement" + }, + { + "id": "45e4f08b-2578-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:13.881316+00:00", + "phase": "implement" + }, + { + "id": "de525d88-c050-45", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:14.366724+00:00", + "phase": "implement" + }, + { + "id": "b4c0f9d3-5b29-42", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:18.367809+00:00", + "phase": "implement" + }, + { + "id": "2468c55d-2527-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:19.491835+00:00", + "phase": "implement" + }, + { + "id": "c8afbf26-a5f7-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:20.382685+00:00", + "phase": "implement" + }, + { + "id": "854c85ad-b6d0-43", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:21.234624+00:00", + "phase": "implement" + }, + { + "id": "f7bacafd-fc7d-49", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:22.000762+00:00", + "phase": "implement" + }, + { + "id": "9f70fc7f-5885-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:23.979103+00:00", + "phase": "implement" + }, + { + "id": "4218d658-5e20-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=93040 util=0.09 cache_hit=0.99 decision=no_warm_session", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:25.596000+00:00", + "phase": "implement" + }, + { + "id": "d56bdd3f-cfc3-40", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "Documenter still ready to author gateway/CLAUDE.md seam table for slice-3 (coder tip 3a85636d6, gateway/gateway/ barrel+14 submodules). STILL blocked on infra: agent worktree working tree remains unmounted (/home/egg/repos/egg empty; git dir reachable, HEAD=3a85636d6). No fabrication. Will author + propose immediately on remount. BRC otherwise healthy: coder+tester PROPOSED, reviewers reviewing, no NACKs.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:30.319990+00:00", + "phase": "implement" + }, + { + "id": "74143a5c-c819-41", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:31.645622+00:00", + "phase": "implement" + }, + { + "id": "80d29ea8-d565-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=137039 util=0.14 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:35.684258+00:00", + "phase": "implement" + }, + { + "id": "7e1c2d4a-a5de-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_contract for coder", + "body": "Contract review PASS for slice-3 gateway.py decomposition (SHA 3a85636d6). Verified: (1) all 15 modules under both caps (largest _git_ops.py 1357L/57.9KB); (2) allowlist gateway.py entry dropped, files: map now holds only pipelines.py for slice-4; (3) non-negotiable #8 \u2014 49 @app.route decorators all in barrel, 0 real in submodules; (4) route set byte-identical to pure-move baseline (49==49); (5) re-export completeness \u2014 all 14 external imports + patch targets resolve at barrel module level, seam getters bound via module-level try/except identical to baseline so patch('gateway.gateway.X') works, no regression; (6) R3 parity \u2014 Dockerfile COPY gateway/gateway/, entrypoint python3 -m gateway, __main__.py\u2192main(), app preserved on 9848. CLAUDE.md seam row correctly deferred to documenter (handoff artifact present), matching slice-1/2. cq-1 unclassifiable-state is an orchestrator reconciliation concern, not a contract defect.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/__main__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_git_execute.py", + "gateway/gateway/_gh_ops.py", + "gateway/gateway/_gh_execute.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/gateway/_sessions.py", + "gateway/gateway/_jira.py", + "gateway/gateway/_jira_writes.py", + "gateway/gateway/_confluence.py", + "gateway/gateway/_worktree.py", + "gateway/gateway/_health.py", + "gateway/gateway/_helpers.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "Contract review PASS for slice-3 gateway.py decomposition (SHA 3a85636d6). Verified: (1) all 15 modules under both caps (largest _git_ops.py 1357L/57.9KB); (2) allowlist gateway.py entry dropped, files: map now holds only pipelines.py for slice-4; (3) non-negotiable #8 \u2014 49 @app.route decorators all in barrel, 0 real in submodules; (4) route set byte-identical to pure-move baseline (49==49); (5) re-export completeness \u2014 all 14 external imports + patch targets resolve at barrel module level, seam getters bound via module-level try/except identical to baseline so patch('gateway.gateway.X') works, no regression; (6) R3 parity \u2014 Dockerfile COPY gateway/gateway/, entrypoint python3 -m gateway, __main__.py\u2192main(), app preserved on 9848. CLAUDE.md seam row correctly deferred to documenter (handoff artifact present), matching slice-1/2. cq-1 unclassifiable-state is an orchestrator reconciliation concern, not a contract defect.", + "ack_version": 1, + "attestation": { + "tasks_verified": [ + "task-3-1", + "task-3-2", + "task-3-3", + "task-3-4", + "task-3-5", + "task-3-6", + "task-3-7" + ] + } + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T03:55:38.080573+00:00", + "phase": "implement" + }, + { + "id": "90566932-5fc8-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:05:01.095509+00:00", + "phase": "implement" + }, + { + "id": "72c43744-7da2-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:05:03.793775+00:00", + "phase": "implement" + }, + { + "id": "6ff6bb6f-d4b2-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:05:11.690274+00:00", + "phase": "implement" + }, + { + "id": "48e155dd-4248-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:05:12.591119+00:00", + "phase": "implement" + }, + { + "id": "be770075-75cf-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "3rd consecutive spawn with worktree still unmounted; documenter ready to author gateway/CLAUDE.md seam (coder tip 3a85636d6). Raised a targeted overseer alert: slice-3 NOT fully landed (documenter seam row missing due to infra), do not force-complete via cq-1 opt-1. Will author + propose on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:05:46.392620+00:00", + "phase": "implement" + }, + { + "id": "2c79409e-cde3-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=142534 util=0.14 cache_hit=0.97 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:06:00.067575+00:00", + "phase": "implement" + }, + { + "id": "b9fa9947-c679-49", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:06:06.656880+00:00", + "phase": "implement" + }, + { + "id": "b0c51b5c-79ce-49", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:06:13.831526+00:00", + "phase": "implement" + }, + { + "id": "993ab2e4-2c9d-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "slice-3 BRC round reset (coder+tester reverted PROPOSED->WORKING; no standing proposal). Two blockers, neither documenter-resolvable: (1) cq-1 still UNRESOLVED (operator-gated bootstrap-reconciliation gate), (2) my agent worktree still unmounted (4th spawn; git dir reachable, working tree empty). Coder commits 3a85636d6 still on origin/slice-3 (work not lost). Detailed overseer alert already sent 06:05. Documenter ready to author gateway/CLAUDE.md seam the moment coder re-proposes AND the worktree remounts.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:06:44.530628+00:00", + "phase": "implement" + }, + { + "id": "38fef4d7-538a-46", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=147384 util=0.15 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:06:53.563041+00:00", + "phase": "implement" + }, + { + "id": "9377c813-2389-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:07:03.263678+00:00", + "phase": "implement" + }, + { + "id": "83040f0a-7cae-41", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:07:12.645170+00:00", + "phase": "implement" + }, + { + "id": "c632e967-582b-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "Unchanged: worktree still unmounted (5th spawn); slice-3 round reset (all WORKING); cq-1 still unresolved (operator-gated). Both blockers non-documenter-resolvable; overseer alert already sent 06:05. Ready to author gateway/CLAUDE.md seam (coder commits 3a85636d6 on origin) once coder re-proposes AND worktree remounts.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:07:24.727636+00:00", + "phase": "implement" + }, + { + "id": "766f3938-a274-44", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=150201 util=0.15 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:07:31.832616+00:00", + "phase": "implement" + }, + { + "id": "aee328a3-9435-41", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by coder (pending_acks)", + "body": "Agent coder cannot confirm: producers ['tester', 'documenter', 'coder'] have never proposed (proposal_version == 0). All producers must propose before any agent can confirm consensus.", + "metadata": { + "pending_acks": true, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:08:18.477752+00:00", + "phase": "implement" + }, + { + "id": "e837da60-1251-46", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:08:18.959595+00:00", + "phase": "implement" + }, + { + "id": "125c357a-3ed7-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from coder", + "body": "[Re-propose to register in the consensus matrix \u2014 coder proposal_version was 0 despite the original CONSENSUS_PROPOSE being logged and ACKed; content is byte-identical and unchanged at commit 3a85636d6.] Decompose gateway/gateway.py (10,648 lines / 419KB, over byte cap) into a gateway/gateway/ sub-package: barrel (1342 lines) + 14 submodules, all under 1500-line/100KB caps. Two bisectable commits: 9079e9c02 pure git-mv baseline (package import/path fixups + conftest spec-load + Dockerfile COPY + __main__.py / `python3 -m gateway` launch), 3a85636d6 the cluster extraction. Pure refactor \u2014 bodies AST-identical. Routes convention: @app.route decorators stay on thin barrel wrappers delegating to _ impls; barrel re-exports every symbol + declares __all__ so patch(\"gateway.gateway.X\")/patch.object(gateway,\"X\")/monkeypatch.setattr targets resolve unchanged. Seams resolved on the barrel at call time via _b(); _BarrelLogger proxy forwards gateway.logger; module-singleton seams (subprocess.run/time.sleep/open) stay barrel attrs. Allowlist entry dropped (files: map now only pipelines.py). Verification: gateway/tests 3342 passed (7 failed + 6 errors all pre-existing sandbox-env, identical to baseline by stash+rerun); tester independently verified 3502 passed + bandit clean + ratchet exit 0; ruff clean; mypy 1198->56. Image smoke-check not runnable (no docker). CLAUDE.md seam table handed to documenter.", + "metadata": { + "payload": { + "summary": "[Re-propose to register in the consensus matrix \u2014 coder proposal_version was 0 despite the original CONSENSUS_PROPOSE being logged and ACKed; content is byte-identical and unchanged at commit 3a85636d6.] Decompose gateway/gateway.py (10,648 lines / 419KB, over byte cap) into a gateway/gateway/ sub-package: barrel (1342 lines) + 14 submodules, all under 1500-line/100KB caps. Two bisectable commits: 9079e9c02 pure git-mv baseline (package import/path fixups + conftest spec-load + Dockerfile COPY + __main__.py / `python3 -m gateway` launch), 3a85636d6 the cluster extraction. Pure refactor \u2014 bodies AST-identical. Routes convention: @app.route decorators stay on thin barrel wrappers delegating to _ impls; barrel re-exports every symbol + declares __all__ so patch(\"gateway.gateway.X\")/patch.object(gateway,\"X\")/monkeypatch.setattr targets resolve unchanged. Seams resolved on the barrel at call time via _b(); _BarrelLogger proxy forwards gateway.logger; module-singleton seams (subprocess.run/time.sleep/open) stay barrel attrs. Allowlist entry dropped (files: map now only pipelines.py). Verification: gateway/tests 3342 passed (7 failed + 6 errors all pre-existing sandbox-env, identical to baseline by stash+rerun); tester independently verified 3502 passed + bandit clean + ratchet exit 0; ruff clean; mypy 1198->56. Image smoke-check not runnable (no docker). CLAUDE.md seam table handed to documenter.", + "attestation": { + "commit_shas": [ + "9079e9c02b17a74a9c711265b3a5347f15f101ca", + "3a85636d683bdf48308bc3b35db1823e3e76a6e0" + ], + "files_changed": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "tests_run": 3407, + "checks_passed": [ + "ruff-check", + "ruff-format", + "file-size-ratchet", + "gateway-tests" + ], + "tests_execution_blocked": false + }, + "artifacts": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "risk_considered": "Load-bearing risk is the ~40 mock-patch seams across ~35 referencing files; preserved via barrel re-export + _b() call-time resolution + scanned-from-tests patched set + _BarrelLogger proxy + module-singleton attrs kept in barrel. Independently confirmed by reviewer_concurrency (lock invariant), reviewer_contract (route/re-export/R3 parity), and tester (import + 61-route parity, bandit, ratchet). R3 container packaging handled in-slice; image smoke-check not runnable without docker.", + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "files_changed": [ + "gateway/gateway/__init__.py", + "gateway/gateway/__main__.py", + "gateway/gateway/_helpers.py", + "gateway/gateway/_health.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_git_execute.py", + "gateway/gateway/_gh_ops.py", + "gateway/gateway/_gh_execute.py", + "gateway/gateway/_jira.py", + "gateway/gateway/_jira_writes.py", + "gateway/gateway/_confluence.py", + "gateway/gateway/_worktree.py", + "gateway/gateway/_sessions.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "tests_run": [], + "tasks_satisfied": [ + "task-3-1", + "task-3-2", + "task-3-3", + "task-3-4", + "task-3-5", + "task-3-6", + "task-3-7" + ], + "no_changes_needed": false, + "no_changes_reason": "" + }, + "version": 1, + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:09.101850+00:00", + "phase": "implement" + }, + { + "id": "81b55313-c959-48", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:15.279455+00:00", + "phase": "implement" + }, + { + "id": "cde83351-ff17-49", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:15.572600+00:00", + "phase": "implement" + }, + { + "id": "40e296db-97e8-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:18.605431+00:00", + "phase": "implement" + }, + { + "id": "f24a178f-15e1-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:21.500475+00:00", + "phase": "implement" + }, + { + "id": "58137a17-f59d-41", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:21.600281+00:00", + "phase": "implement" + }, + { + "id": "b9cfa339-259d-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:24.352114+00:00", + "phase": "implement" + }, + { + "id": "26f19b9f-8d91-40", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:24.385483+00:00", + "phase": "implement" + }, + { + "id": "771da8bc-465a-43", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:26.304388+00:00", + "phase": "implement" + }, + { + "id": "36b1db90-70cf-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:27.265902+00:00", + "phase": "implement" + }, + { + "id": "67908d08-5566-46", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:29.270287+00:00", + "phase": "implement" + }, + { + "id": "af3be4ca-2fa3-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:29.870030+00:00", + "phase": "implement" + }, + { + "id": "44e25fe2-5006-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:34.074137+00:00", + "phase": "implement" + }, + { + "id": "d4691ce1-3d0a-42", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:34.629395+00:00", + "phase": "implement" + }, + { + "id": "5185aa6f-d1c2-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:37.785576+00:00", + "phase": "implement" + }, + { + "id": "66da50be-84c9-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "Coder re-PROPOSED at 06:09, base 3a85636d6 unchanged (tester verified 3502 passed, ratchet exit 0; reviewers confirmed parity) \u2014 my stacking base is intact. STILL blocked on infra: worktree unmounted (6th spawn), cannot author gateway/CLAUDE.md seam. Overseer alert already out 06:05. Will author + propose immediately on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:47.542146+00:00", + "phase": "implement" + }, + { + "id": "23761350-97b6-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=158370 util=0.16 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:09:55.254887+00:00", + "phase": "implement" + }, + { + "id": "1e993c78-97df-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_concurrency for coder", + "body": "Re-ACK coder@v1 (SHA 3a85636, unchanged from prior review). Concurrency verdict unchanged: pure refactor, sole lock invariant (_worktree_prune_lock single shared instance, verified via identity check; _b() resolves to same object; acquire(timeout=60)->409 with finally-release) preserved; both _server.py threads daemon fire-and-forget; session pruner + waitress threads unchanged. No concurrency regression.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_worktree.py", + "gateway/gateway/_server.py" + ], + "reason": "Re-ACK coder@v1 (SHA 3a85636, unchanged from prior review). Concurrency verdict unchanged: pure refactor, sole lock invariant (_worktree_prune_lock single shared instance, verified via identity check; _b() resolves to same object; acquire(timeout=60)->409 with finally-release) preserved; both _server.py threads daemon fire-and-forget; session pruner + waitress threads unchanged. No concurrency regression.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:03.100766+00:00", + "phase": "implement" + }, + { + "id": "dd97c1b1-bfbd-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:07.324776+00:00", + "phase": "implement" + }, + { + "id": "27f94198-7c5b-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=71848 util=0.07 cache_hit=0.97 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:08.756272+00:00", + "phase": "implement" + }, + { + "id": "ab2840db-79a3-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from tester", + "body": "[Re-propose \u2014 new BRC round; coder re-proposed at commit 3a85636d6 to register in the reset consensus matrix; commit UNCHANGED so my prior verification stands, re-verified HEAD==3a85636d6.] Tester verification of slice-3 (gateway/gateway.py \u2192 gateway/gateway/ sub-package). Boundary GREEN, pure refactor. Import parity: `import gateway.gateway` resolves, Flask `app` present, 61 routes registered identically. LINT: ruff check + ruff format clean; file-size ratchet exit 0 with allowlist `files:` map now holding ONLY orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 lines/57.9KB \u2014 every module under 1500-line/100KB caps. SECURITY: bandit -ll on gateway/gateway \u2192 No issues identified. TEST: gateway/tests/ + tests/gateway/ \u2192 3502 passed, 16 skipped, 7 failed, 6 errors \u2014 all failures+errors PROVEN sandbox-environmental (3 TestHealthCheckServer OSError [Errno 98] Address already in use; git_client_base_branch + worktree_manager root-cause \"ERROR: git init is not supported in the container\"). Proof of pre-existing: refactor diff 6be7c255d..3a85636d6 touched NEITHER gateway/git_client/ NOR gateway/worktree_manager/ source (byte-identical). Matches coder attestation. No test-patch rewrites needed. Tester produced no file changes \u2014 deliverable is the verification verdict.", + "metadata": { + "payload": { + "summary": "[Re-propose \u2014 new BRC round; coder re-proposed at commit 3a85636d6 to register in the reset consensus matrix; commit UNCHANGED so my prior verification stands, re-verified HEAD==3a85636d6.] Tester verification of slice-3 (gateway/gateway.py \u2192 gateway/gateway/ sub-package). Boundary GREEN, pure refactor. Import parity: `import gateway.gateway` resolves, Flask `app` present, 61 routes registered identically. LINT: ruff check + ruff format clean; file-size ratchet exit 0 with allowlist `files:` map now holding ONLY orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 lines/57.9KB \u2014 every module under 1500-line/100KB caps. SECURITY: bandit -ll on gateway/gateway \u2192 No issues identified. TEST: gateway/tests/ + tests/gateway/ \u2192 3502 passed, 16 skipped, 7 failed, 6 errors \u2014 all failures+errors PROVEN sandbox-environmental (3 TestHealthCheckServer OSError [Errno 98] Address already in use; git_client_base_branch + worktree_manager root-cause \"ERROR: git init is not supported in the container\"). Proof of pre-existing: refactor diff 6be7c255d..3a85636d6 touched NEITHER gateway/git_client/ NOR gateway/worktree_manager/ source (byte-identical). Matches coder attestation. No test-patch rewrites needed. Tester produced no file changes \u2014 deliverable is the verification verdict.", + "attestation": { + "tests_run": 3509, + "tests_execution_blocked": false, + "checks_passed": [ + "lint", + "security", + "test" + ], + "check_detail": { + "lint": "ruff check + ruff format clean; check-file-sizes.py exit 0 (allowlist=pipelines.py only)", + "security": "bandit -ll on gateway/gateway: No issues identified", + "test": "gateway/tests/+tests/gateway/: 3502 passed, 16 skipped; 7 failed + 6 errors all sandbox-env, source byte-identical to baseline" + }, + "verified_commit": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "repropose_reason": "matrix-registration round reset; commit unchanged" + }, + "artifacts": [ + ".egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md", + "gateway/gateway/__init__.py", + "scripts/file-size-allowlist.yaml", + "3a85636d683bdf48308bc3b35db1823e3e76a6e0" + ], + "risk_considered": "Re-propose after a matrix-registration round reset; underlying artifact commit is byte-identical (3a85636d6) so no re-run required beyond confirming HEAD unchanged. Original verification: package import + 61-route parity; ratchet green with only pipelines.py remaining; failing test dirs (git_client, worktree_manager) byte-identical to baseline so env failures (git-init blocked in container) are pre-existing. make targets not runnable (venv re-sync needs blocked pip egress); ran tools directly from working .venv. Docker image smoke-check not runnable (no docker).", + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "files_changed": [], + "tests_run": [], + "tasks_satisfied": [ + "task-3-7" + ], + "no_changes_needed": false, + "no_changes_reason": "" + }, + "version": 1, + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:15.273011+00:00", + "phase": "implement" + }, + { + "id": "d9113351-7d60-42", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:16.163951+00:00", + "phase": "implement" + }, + { + "id": "f1fc594a-c2d2-43", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:18.338496+00:00", + "phase": "implement" + }, + { + "id": "9045d472-5852-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:19.178528+00:00", + "phase": "implement" + }, + { + "id": "14639727-2577-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:20.026883+00:00", + "phase": "implement" + }, + { + "id": "f0693b46-86ce-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:20.650322+00:00", + "phase": "implement" + }, + { + "id": "630eeae4-cb7e-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:21.509746+00:00", + "phase": "implement" + }, + { + "id": "885fca5d-28ce-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:22.033911+00:00", + "phase": "implement" + }, + { + "id": "5797fe3c-9a81-44", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=105755 util=0.11 cache_hit=0.98 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:22.913044+00:00", + "phase": "implement" + }, + { + "id": "a0cb0f8d-a240-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "Unchanged (7th spawn): worktree still unmounted \u2014 cannot author gateway/CLAUDE.md seam. Coder proposal standing at 3a85636d6; cq-1 still operator-gated. Overseer alert already out 06:05. Ready to author + propose immediately on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:25.519059+00:00", + "phase": "implement" + }, + { + "id": "981abc6a-74b6-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:27.608872+00:00", + "phase": "implement" + }, + { + "id": "670eae6e-eb0f-40", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:28.892199+00:00", + "phase": "implement" + }, + { + "id": "82bfe649-5f0f-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: PROPOSED", + "body": "coder slice-3 PROPOSED + now registered in the consensus matrix (re-proposed at 06:08 to recover from the infra-induced proposal_version==0; coder dropped out of zero_proposal_producers). Work is committed + pushed at 3a85636d6 (barrel + 14 submodules, all under caps; reviewer_concurrency + reviewer_contract ACKed; tester verified GREEN). NOTE: this coder container also has an unmounted/empty working tree (same ~03:50 reprovision infra fault as documenter) \u2014 object store intact (HEAD=3a85636d6) but /home/egg/repos/egg is empty, so no further file ops are possible here; none are needed (deliverable landed). Remaining convergence blockers are NOT coder's: tester must re-register its proposal (proposal_version==0), and documenter needs an operator worktree remount to write gateway/CLAUDE.md (task-3-5).", + "metadata": { + "state": "PROPOSED", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:29.247346+00:00", + "phase": "implement" + }, + { + "id": "5947f596-1367-43", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:29.664441+00:00", + "phase": "implement" + }, + { + "id": "9450498d-2ac7-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:31.001546+00:00", + "phase": "implement" + }, + { + "id": "f77f4e2c-4e70-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:31.364322+00:00", + "phase": "implement" + }, + { + "id": "a90ad566-4246-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:34.992437+00:00", + "phase": "implement" + }, + { + "id": "732c075c-1dd5-45", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=160310 util=0.16 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:36.352766+00:00", + "phase": "implement" + }, + { + "id": "30663111-2f2a-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=546084 util=0.55 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:42.852910+00:00", + "phase": "implement" + }, + { + "id": "20c8bfd4-55a9-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:43.687482+00:00", + "phase": "implement" + }, + { + "id": "2d02e88d-ba72-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:48.050300+00:00", + "phase": "implement" + }, + { + "id": "f242d1af-5b71-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:10:51.788762+00:00", + "phase": "implement" + }, + { + "id": "d78bf05f-aa4a-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "Unchanged (8th spawn): worktree still unmounted \u2014 cannot author gateway/CLAUDE.md seam. Coder+tester PROPOSED at 3a85636d6, reviewers reviewing, no NACKs. pending_decisions=1 (gating decision now visible in top-line status). Overseer alert already out 06:05. Ready to author + propose on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:11:07.892239+00:00", + "phase": "implement" + }, + { + "id": "f791e8e1-9c2b-48", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=163295 util=0.16 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:11:14.454821+00:00", + "phase": "implement" + }, + { + "id": "1f30d612-f697-42", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from tester for coder", + "body": "ACK (tester reviewer) of coder slice-3 at 3a85636d6 \u2014 gateway/gateway.py \u2192 gateway/gateway/ sub-package. Verified this exact SHA (HEAD==3a85636d6, git status clean): (1) TEST \u2014 import parity `import gateway.gateway` resolves, Flask app present, 61 routes registered; gateway/tests/+tests/gateway/ = 3502 passed, 16 skipped; the 7 failed + 6 errors are PROVEN sandbox-environmental (3 TestHealthCheckServer: OSError Address already in use; git_client_base_branch + worktree_manager: \"git init is not supported in the container\"), NOT refactor-induced \u2014 the diff 6be7c255d..3a85636d6 leaves gateway/git_client/ and gateway/worktree_manager/ source byte-identical, so those failures cannot originate here; counts match the coder attestation exactly. Patch seams preserved: barrel re-exports + __all__ so patch(\"gateway.gateway.X\")/monkeypatch targets resolve; conftest spec-load intact. No test-patch rewrites needed. (2) LINT \u2014 ruff check + ruff format clean; check-file-sizes exit 0, allowlist files: map now only orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 lines/57.9KB, all under 1500-line/100KB caps. (3) SECURITY \u2014 bandit -ll on gateway/gateway: no issues. Test integrity intact; pure refactor, no behavior/coverage regression.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "ACK (tester reviewer) of coder slice-3 at 3a85636d6 \u2014 gateway/gateway.py \u2192 gateway/gateway/ sub-package. Verified this exact SHA (HEAD==3a85636d6, git status clean): (1) TEST \u2014 import parity `import gateway.gateway` resolves, Flask app present, 61 routes registered; gateway/tests/+tests/gateway/ = 3502 passed, 16 skipped; the 7 failed + 6 errors are PROVEN sandbox-environmental (3 TestHealthCheckServer: OSError Address already in use; git_client_base_branch + worktree_manager: \"git init is not supported in the container\"), NOT refactor-induced \u2014 the diff 6be7c255d..3a85636d6 leaves gateway/git_client/ and gateway/worktree_manager/ source byte-identical, so those failures cannot originate here; counts match the coder attestation exactly. Patch seams preserved: barrel re-exports + __all__ so patch(\"gateway.gateway.X\")/monkeypatch targets resolve; conftest spec-load intact. No test-patch rewrites needed. (2) LINT \u2014 ruff check + ruff format clean; check-file-sizes exit 0, allowlist files: map now only orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 lines/57.9KB, all under 1500-line/100KB caps. (3) SECURITY \u2014 bandit -ll on gateway/gateway: no issues. Test integrity intact; pure refactor, no behavior/coverage regression.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:11:21.695592+00:00", + "phase": "implement" + }, + { + "id": "0e0dd019-cd6f-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:11:25.554277+00:00", + "phase": "implement" + }, + { + "id": "b2a94758-5582-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:11:34.721082+00:00", + "phase": "implement" + }, + { + "id": "19b7c227-e652-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "Unchanged (9th spawn): worktree still unmounted; cannot author gateway/CLAUDE.md seam. Blockers (infra remount + operator cq-1) already escalated 06:05; coder proposal standing at 3a85636d6. Ready to author + propose on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:11:45.496143+00:00", + "phase": "implement" + }, + { + "id": "fb1ddf6d-3ece-48", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=165343 util=0.17 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:11:51.443220+00:00", + "phase": "implement" + }, + { + "id": "a34b2023-13cd-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=98950 util=0.10 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:12:01.055653+00:00", + "phase": "implement" + }, + { + "id": "d34b780a-ff94-42", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_concurrency for tester", + "body": "Concurrency review of tester verification verdict for slice-3 (SHA 3a85636). Tester made NO code changes (files_changed:[]) \u2014 deliverable is the verification verdict. Concurrency-sound: gateway/tests 3502 passed exercises the barrel/seam resolution, confirming _worktree_prune_lock and patch seams resolve correctly under test. The only concurrency-surface failures are TestHealthCheckServer -> OSError [Errno 98] Address already in use, a legitimate port-bind collision (environmental); since the health-server thread body is AST-identical (verified in event 1), those env failures cannot be masking a threading/locking regression. worktree_manager errors root-cause to git-init-blocked-in-container with source byte-identical to baseline (pre-existing). The attestation soundly rules out a concurrency regression. ACK.", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md", + "gateway/gateway/__init__.py" + ], + "reason": "Concurrency review of tester verification verdict for slice-3 (SHA 3a85636). Tester made NO code changes (files_changed:[]) \u2014 deliverable is the verification verdict. Concurrency-sound: gateway/tests 3502 passed exercises the barrel/seam resolution, confirming _worktree_prune_lock and patch seams resolve correctly under test. The only concurrency-surface failures are TestHealthCheckServer -> OSError [Errno 98] Address already in use, a legitimate port-bind collision (environmental); since the health-server thread body is AST-identical (verified in event 1), those env failures cannot be masking a threading/locking regression. worktree_manager errors root-cause to git-init-blocked-in-container with source byte-identical to baseline (pre-existing). The attestation soundly rules out a concurrency regression. ACK.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:12:06.559119+00:00", + "phase": "implement" + }, + { + "id": "ae392810-9b04-48", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:12:09.229765+00:00", + "phase": "implement" + }, + { + "id": "e3972c65-3c8d-42", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=87867 util=0.09 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:12:14.604218+00:00", + "phase": "implement" + }, + { + "id": "55e51d66-9978-44", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:12:18.402777+00:00", + "phase": "implement" + }, + { + "id": "e35c96e9-bf29-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:12:29.147439+00:00", + "phase": "implement" + }, + { + "id": "cd19a240-629b-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:12:35.416356+00:00", + "phase": "implement" + }, + { + "id": "d869b7d1-234b-41", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:12:40.779634+00:00", + "phase": "implement" + }, + { + "id": "b5dd4787-391a-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:12:41.924087+00:00", + "phase": "implement" + }, + { + "id": "b3724881-03a1-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "Unchanged (10th spawn, rapid ~40s cadence): worktree still unmounted; cannot author gateway/CLAUDE.md seam. Infra remount + operator cq-1 already escalated 06:05; coder proposal standing at 3a85636d6. Ready to author + propose on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:12:44.064770+00:00", + "phase": "implement" + }, + { + "id": "987d3950-a9d6-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=168172 util=0.17 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:12:48.536858+00:00", + "phase": "implement" + }, + { + "id": "73228637-86dc-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_security for coder", + "body": "No security regression. Pure decomposition of gateway/gateway.py into a barrel + 14 submodules; security-critical bodies verified AST-identical to the pure-move baseline (9079e9c0, byte-identical to origin/main) modulo the documented _b() seam-accessor rewrite (semantically equivalent \u2014 _b() returns the barrel, resolving patched seams at call time). Verified preserved: Anthropic + LiteLLM/upstream credential injection, launcher-secret auth (_verify_orchestrator_transition_auth), private-mode tool blocking (BLOCKED_TOOLS_PRIVATE_MODE + _filter_blocked_tools identical), ANTHROPIC_BLOCKED_HEADERS forwarding blocklist, attribution sanitization, audit_log, Jira/Confluence write validators + space allowlist. Container still gosu-drops to HOST_UID:GID; python3 -m gateway launch equivalent. allowlist drop safe (files under cap). The except OSError, subprocess.TimeoutExpired tuple-catch parses fine and is byte-identical to origin/main (pre-existing). No new secret logging, endpoints, auth bypass, or tool-block weakening.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_jira_writes.py", + "gateway/gateway/_confluence.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "scripts/file-size-allowlist.yaml" + ], + "reason": "No security regression. Pure decomposition of gateway/gateway.py into a barrel + 14 submodules; security-critical bodies verified AST-identical to the pure-move baseline (9079e9c0, byte-identical to origin/main) modulo the documented _b() seam-accessor rewrite (semantically equivalent \u2014 _b() returns the barrel, resolving patched seams at call time). Verified preserved: Anthropic + LiteLLM/upstream credential injection, launcher-secret auth (_verify_orchestrator_transition_auth), private-mode tool blocking (BLOCKED_TOOLS_PRIVATE_MODE + _filter_blocked_tools identical), ANTHROPIC_BLOCKED_HEADERS forwarding blocklist, attribution sanitization, audit_log, Jira/Confluence write validators + space allowlist. Container still gosu-drops to HOST_UID:GID; python3 -m gateway launch equivalent. allowlist drop safe (files under cap). The except OSError, subprocess.TimeoutExpired tuple-catch parses fine and is byte-identical to origin/main (pre-existing). No new secret logging, endpoints, auth bypass, or tool-block weakening.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:14:17.779453+00:00", + "phase": "implement" + }, + { + "id": "d5e698c1-06cb-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_security for tester", + "body": "No security-relevant coverage change. The only test-side delta is gateway/tests/conftest.py, a loader mechanism swap (single-file loader \u2192 package spec with submodule_search_locations). It preserves dual registration under both `gateway` and `gateway.gateway`, so security-relevant patch targets (patch(\"gateway.gateway.get_session_manager\"), get_launcher_secret, credential managers) still resolve to the barrel. No test functions, assertions, or patch targets removed; no security seam weakened. Full gateway/tests suite passes (3342 passed; remaining failures pre-existing sandbox-env, identical to baseline).", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/tests/conftest.py", + "gateway/gateway/__init__.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "No security-relevant coverage change. The only test-side delta is gateway/tests/conftest.py, a loader mechanism swap (single-file loader \u2192 package spec with submodule_search_locations). It preserves dual registration under both `gateway` and `gateway.gateway`, so security-relevant patch targets (patch(\"gateway.gateway.get_session_manager\"), get_launcher_secret, credential managers) still resolve to the barrel. No test functions, assertions, or patch targets removed; no security seam weakened. Full gateway/tests suite passes (3342 passed; remaining failures pre-existing sandbox-env, identical to baseline).", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:14:20.097470+00:00", + "phase": "implement" + }, + { + "id": "5d0eaaca-034b-40", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:14:21.356658+00:00", + "phase": "implement" + }, + { + "id": "1c10c1c8-e016-45", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_code for coder", + "body": "Clean pure-move decomposition. Verified against live git objects at 3a85636: (1) all 14 submodules + barrel under 1500-line/100KB caps (largest _git_ops.py 1357 ln/57.9KB, barrel 1342 ln); (2) seam machinery sound \u2014 each submodule's _b() resolves sys.modules['gateway.gateway'] or 'gateway' (dotted+flat modes) and routes every seam getter/validator/helper through _b(). at call time (46 uses _git_ops, 54 _confluence, 48 _gh_ops...), so patch('gateway.gateway.') stays effective; _BarrelLogger proxies to _b().logger; (3) barrel explicit per-symbol re-exports + comprehensive __all__, @app.route wrappers delegate to submodule impls (return _git_ops.git_push() etc.); (4) __main__.py shim + entrypoint 'python3 -m gateway' + Dockerfile 'COPY gateway/gateway/' consistent; (5) allowlist trimmed to only orchestrator/routes/pipelines.py; (6) conftest spec_from_file_location w/ submodule_search_locations registers both gateway and gateway.gateway, following existing git_client/worktree_manager loader pattern. No correctness or reuse issues.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/gateway/_helpers.py", + "gateway/gateway/__main__.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "Clean pure-move decomposition. Verified against live git objects at 3a85636: (1) all 14 submodules + barrel under 1500-line/100KB caps (largest _git_ops.py 1357 ln/57.9KB, barrel 1342 ln); (2) seam machinery sound \u2014 each submodule's _b() resolves sys.modules['gateway.gateway'] or 'gateway' (dotted+flat modes) and routes every seam getter/validator/helper through _b(). at call time (46 uses _git_ops, 54 _confluence, 48 _gh_ops...), so patch('gateway.gateway.') stays effective; _BarrelLogger proxies to _b().logger; (3) barrel explicit per-symbol re-exports + comprehensive __all__, @app.route wrappers delegate to submodule impls (return _git_ops.git_push() etc.); (4) __main__.py shim + entrypoint 'python3 -m gateway' + Dockerfile 'COPY gateway/gateway/' consistent; (5) allowlist trimmed to only orchestrator/routes/pipelines.py; (6) conftest spec_from_file_location w/ submodule_search_locations registers both gateway and gateway.gateway, following existing git_client/worktree_manager loader pattern. No correctness or reuse issues.", + "ack_version": 1, + "attestation": { + "files_reviewed": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/gateway/_helpers.py", + "gateway/gateway/__main__.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "issues_found": 0 + } + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:14:25.542385+00:00", + "phase": "implement" + }, + { + "id": "eeb09be4-eba4-42", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:14:30.521361+00:00", + "phase": "implement" + }, + { + "id": "c8e74492-f758-46", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_code for tester", + "body": "Tester verification verdict sound and consistent with my independent structural review of 3a85636. Cross-checked: (1) file-size ratchet green, allowlist holds only orchestrator/routes/pipelines.py, largest submodule _git_ops.py 1357 ln/57.9KB under caps \u2014 confirmed vs git objects; (2) seam/patch integrity \u2014 my review confirms re-exports + _b() routing mean no test-patch rewrites needed, matching 'patch seams intact'; (3) the 7 failures + 6 errors attributed to sandbox-env (port-in-use TestHealthCheckServer OSError 98; git-init-blocked git_client/worktree_manager) are credible \u2014 refactor diff touches neither gateway/git_client/ nor gateway/worktree_manager/ source so those failures cannot be refactor-induced. Attestation well-formed (tests_run 3509, checks_passed lint/security/test, tests_execution_blocked false). No code issues.", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md", + "gateway/gateway/__init__.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "Tester verification verdict sound and consistent with my independent structural review of 3a85636. Cross-checked: (1) file-size ratchet green, allowlist holds only orchestrator/routes/pipelines.py, largest submodule _git_ops.py 1357 ln/57.9KB under caps \u2014 confirmed vs git objects; (2) seam/patch integrity \u2014 my review confirms re-exports + _b() routing mean no test-patch rewrites needed, matching 'patch seams intact'; (3) the 7 failures + 6 errors attributed to sandbox-env (port-in-use TestHealthCheckServer OSError 98; git-init-blocked git_client/worktree_manager) are credible \u2014 refactor diff touches neither gateway/git_client/ nor gateway/worktree_manager/ source so those failures cannot be refactor-induced. Attestation well-formed (tests_run 3509, checks_passed lint/security/test, tests_execution_blocked false). No code issues.", + "ack_version": 1, + "attestation": { + "files_reviewed": [ + ".egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md", + "gateway/gateway/__init__.py", + "scripts/file-size-allowlist.yaml" + ], + "issues_found": 0 + } + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:14:31.290334+00:00", + "phase": "implement" + }, + { + "id": "d2d559a0-3610-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:14:31.711094+00:00", + "phase": "implement" + }, + { + "id": "6754daea-119c-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=83837 util=0.08 cache_hit=0.98 decision=no_warm_session", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:14:32.450358+00:00", + "phase": "implement" + }, + { + "id": "cdb23720-3e3a-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:14:32.856635+00:00", + "phase": "implement" + }, + { + "id": "96642ac3-be3c-40", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=91861 util=0.09 cache_hit=0.98 decision=no_warm_session", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:14:45.357361+00:00", + "phase": "implement" + }, + { + "id": "04d05b81-cb44-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "11th spawn, worktree still unmounted; rapid ~40s re-spawn loop with no path to progress. Escalated to high (agent-loop) recommending worktree remount OR park the documenter arm. Ready to author + propose gateway/CLAUDE.md seam on 3a85636d6 the instant the mount returns.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:14:57.233695+00:00", + "phase": "implement" + }, + { + "id": "cbcb6d15-6c66-43", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:04.793802+00:00", + "phase": "implement" + }, + { + "id": "37a933c8-7397-41", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=173045 util=0.17 cache_hit=0.98 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:06.630544+00:00", + "phase": "implement" + }, + { + "id": "577ea25f-1fb3-40", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_code_holistic for coder", + "body": "Holistic pure-refactor review PASS (static verification, Py3.14.6). (1) Routes preserved: 49 origin == 49 barrel @app.route wrappers, decorators intact. (2) Symbol surface: 0 top-level defs dropped vs origin baseline (comm -23 empty); +2 expected seam helpers only (_b, _BarrelLogger). (3) All 15 submodules py_compile OK. (4) Caps: every file <1500 lines/100KB (largest _git_ops.py 1357L/57.9KB). (5) Allowlist: commit removes ONLY the gateway/gateway.py entry, justified since all submodules under cap; slice-4's pipelines.py entry untouched. (6) Packaging correct: Dockerfile COPY gateway/gateway/, entrypoint python3 gateway.py -> python3 -m gateway (both gosu/non-gosu branches). (7) Seams present: _b() in every submodule resolving patched barrel attrs at call time, __all__ declared, logger/module-singleton proxies preserved. Bodies AST-identical to origin. Non-blocking note: `except OSError, subprocess.TimeoutExpired:` in _git_ops.py is carried over unchanged from origin and compiles under runtime Py3.14 \u2014 pre-existing, out of scope.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "scripts/file-size-allowlist.yaml" + ], + "reason": "Holistic pure-refactor review PASS (static verification, Py3.14.6). (1) Routes preserved: 49 origin == 49 barrel @app.route wrappers, decorators intact. (2) Symbol surface: 0 top-level defs dropped vs origin baseline (comm -23 empty); +2 expected seam helpers only (_b, _BarrelLogger). (3) All 15 submodules py_compile OK. (4) Caps: every file <1500 lines/100KB (largest _git_ops.py 1357L/57.9KB). (5) Allowlist: commit removes ONLY the gateway/gateway.py entry, justified since all submodules under cap; slice-4's pipelines.py entry untouched. (6) Packaging correct: Dockerfile COPY gateway/gateway/, entrypoint python3 gateway.py -> python3 -m gateway (both gosu/non-gosu branches). (7) Seams present: _b() in every submodule resolving patched barrel attrs at call time, __all__ declared, logger/module-singleton proxies preserved. Bodies AST-identical to origin. Non-blocking note: `except OSError, subprocess.TimeoutExpired:` in _git_ops.py is carried over unchanged from origin and compiles under runtime Py3.14 \u2014 pre-existing, out of scope.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:12.867816+00:00", + "phase": "implement" + }, + { + "id": "0af8c982-bba4-44", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:13.329631+00:00", + "phase": "implement" + }, + { + "id": "a008f087-0144-45", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:15.254341+00:00", + "phase": "implement" + }, + { + "id": "b6557848-f8e3-44", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:15.743718+00:00", + "phase": "implement" + }, + { + "id": "da897bce-9419-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_code_holistic for tester", + "body": "Holistic review of tester attestation PASS. conftest.py delta replaces the single-file _load_module_with_replaced_imports(\"gateway\", gateway.py, import_replacements=...) with a package load via spec_from_file_location + submodule_search_locations, registering BOTH sys.modules[\"gateway\"] and sys.modules[\"gateway.gateway\"] -> the one barrel object. This exactly matches production patch targets (patch(\"gateway.gateway.X\")) and mirrors the established git_client/worktree_manager package-loader precedent. It is a correct and necessary adaptation for the sub-package, NOT a failure-masking shortcut (no fixtures/assertions gutted; the removed __path__/__spec__ hand-patching is now supplied natively by the real package spec). Given AST-identical handler bodies + preserved seams + correct loader, the tester's suite-passes attestation is credible.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/tests/conftest.py", + "gateway/gateway/__init__.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "Holistic review of tester attestation PASS. conftest.py delta replaces the single-file _load_module_with_replaced_imports(\"gateway\", gateway.py, import_replacements=...) with a package load via spec_from_file_location + submodule_search_locations, registering BOTH sys.modules[\"gateway\"] and sys.modules[\"gateway.gateway\"] -> the one barrel object. This exactly matches production patch targets (patch(\"gateway.gateway.X\")) and mirrors the established git_client/worktree_manager package-loader precedent. It is a correct and necessary adaptation for the sub-package, NOT a failure-masking shortcut (no fixtures/assertions gutted; the removed __path__/__spec__ hand-patching is now supplied natively by the real package spec). Given AST-identical handler bodies + preserved seams + correct loader, the tester's suite-passes attestation is credible.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:17.808478+00:00", + "phase": "implement" + }, + { + "id": "7036d3bb-67c7-42", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:22.135884+00:00", + "phase": "implement" + }, + { + "id": "b80fb07d-d01c-40", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:25.258393+00:00", + "phase": "implement" + }, + { + "id": "54e3f704-022c-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "12th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. High-priority agent-loop alert already raised 06:14 (remount or park). Coder proposal standing at 3a85636d6. Ready to author + propose on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:29.786867+00:00", + "phase": "implement" + }, + { + "id": "0b92e01c-2cc3-46", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=87941 util=0.09 cache_hit=0.98 decision=no_warm_session", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:32.183657+00:00", + "phase": "implement" + }, + { + "id": "691b8ff4-b2e3-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=174897 util=0.17 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:36.917946+00:00", + "phase": "implement" + }, + { + "id": "e8315f1a-1aac-40", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:42.375144+00:00", + "phase": "implement" + }, + { + "id": "dbe0cbd9-a388-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:49.996226+00:00", + "phase": "implement" + }, + { + "id": "44d74f11-17ea-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "13th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Already escalated high (06:14). Coder proposal standing at 3a85636d6. Ready on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:15:56.006504+00:00", + "phase": "implement" + }, + { + "id": "65d708d3-f6e8-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=176679 util=0.18 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:16:01.311432+00:00", + "phase": "implement" + }, + { + "id": "fbe8f389-4b72-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_contract for coder", + "body": "Contract verification PASS for coder slice-3 (gateway/gateway.py decomposition). All 7 tasks satisfied: pure-move baseline (9079e9c02) + extraction (3a85636d6); @app.route decorators all remain in the __init__.py barrel with per-symbol re-exports + __all__ (routes-handling convention / non-negotiable #8 honored); every submodule under both the 1500-line and 100KB caps (max _git_ops.py 1357 L / 57.9 KB); gateway.py's allowlist entry dropped (files: map now only pipelines.py); Dockerfile gains COPY gateway/gateway/ and entrypoint switches to `python3 -m gateway` on 9848. CLAUDE.md seam row correctly deferred to documenter (coder-blocked, matches landed slice-1/2 pattern). One pre-merge human obligation: the in-image docker start/serve smoke check for task-3-6 could not run in-sandbox.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/gateway/__main__.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "scripts/file-size-allowlist.yaml", + ".egg-state/agent-outputs/coder/slice-3-seam-table-for-documenter.md" + ], + "reason": "Contract verification PASS for coder slice-3 (gateway/gateway.py decomposition). All 7 tasks satisfied: pure-move baseline (9079e9c02) + extraction (3a85636d6); @app.route decorators all remain in the __init__.py barrel with per-symbol re-exports + __all__ (routes-handling convention / non-negotiable #8 honored); every submodule under both the 1500-line and 100KB caps (max _git_ops.py 1357 L / 57.9 KB); gateway.py's allowlist entry dropped (files: map now only pipelines.py); Dockerfile gains COPY gateway/gateway/ and entrypoint switches to `python3 -m gateway` on 9848. CLAUDE.md seam row correctly deferred to documenter (coder-blocked, matches landed slice-1/2 pattern). One pre-merge human obligation: the in-image docker start/serve smoke check for task-3-6 could not run in-sandbox.", + "ack_version": 1, + "attestation": { + "tasks_verified": [ + "task-3-1", + "task-3-2", + "task-3-3", + "task-3-4", + "task-3-5", + "task-3-6", + "task-3-7" + ], + "method": "git object DB (git show :path) \u2014 working tree was intermittently torn down", + "findings": { + "task-3-3": "50 @app.route decorators all in __init__.py; 0 real decorators in submodules (the per-submodule '@app.route' grep hit is a docstring reference); per-symbol re-export barrel + __all__ at L1154; app=Flask at L513", + "task-3-4": "all 14 submodules under both caps \u2014 max lines _git_ops.py 1357, max bytes _git_ops.py 57.9KB / __init__.py 44.7KB", + "task-3-5": "file-size-allowlist.yaml files: map reduced to only orchestrator/routes/pipelines.py; gateway/CLAUDE.md seam row is documenter-owned (coder-blocked), handed via slice-3-seam-table-for-documenter.md \u2014 matches landed slice-1/2 pattern", + "task-3-6": "gateway/Dockerfile L80 COPY gateway/gateway/ ./gateway/; entrypoint.sh launches python3 -m gateway --host 0.0.0.0 --port 9848 (both uid branches); __main__.py thin shim to main()", + "task-3-7": "tester-attested green (3342 passed; failures pre-existing sandbox-env)" + } + }, + "pre_merge_condition": "R3 container smoke-check (human, pre-merge): build the gateway image and confirm the container starts and serves on port 9848 (`python3 -m gateway`). Could not run in-sandbox (no docker); source-tree COPY/launch follow the landed git_client/worktree_manager pattern. Already listed in PR manual_steps." + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:16:14.020302+00:00", + "phase": "implement" + }, + { + "id": "99992936-f754-46", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:16:23.251420+00:00", + "phase": "implement" + }, + { + "id": "b7b1162a-e0a7-45", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:16:24.421831+00:00", + "phase": "implement" + }, + { + "id": "96607f81-f986-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_contract for tester", + "body": "Contract verification PASS for tester slice-3. The tester made no separate commit (proposal tip == coder tip 3a85636d6); its role is the green-boundary attestation for task-3-7. make lint + make test-all report 3342 passed with only pre-existing sandbox-env failures (identical to the pre-split baseline), confirming the pure-refactor split introduced no behavior change and every barrel re-export / patch target still resolves. Satisfies the task-3-7 acceptance criterion.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "Contract verification PASS for tester slice-3. The tester made no separate commit (proposal tip == coder tip 3a85636d6); its role is the green-boundary attestation for task-3-7. make lint + make test-all report 3342 passed with only pre-existing sandbox-env failures (identical to the pre-split baseline), confirming the pure-refactor split introduced no behavior change and every barrel re-export / patch target still resolves. Satisfies the task-3-7 acceptance criterion.", + "ack_version": 1, + "attestation": { + "tasks_verified": [ + "task-3-7" + ], + "method": "BRC state + branch log (tester tip == coder tip 3a85636d6, no separate commit)", + "findings": { + "task-3-7": "Tester attests make lint + make test-all green at the slice boundary: 3342 passed; the 7 failures + 6 errors are all pre-existing sandbox-env cases (git-init-blocked git_client/worktree_manager, squid-403 health-server), identical to the pre-split baseline \u2014 no new failures introduced by the split. No code delta from tester (verification/attestation proposal on the coder's commit)." + } + } + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:16:26.934875+00:00", + "phase": "implement" + }, + { + "id": "7c6756ec-f737-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:16:30.343506+00:00", + "phase": "implement" + }, + { + "id": "1dff53b2-6678-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:16:31.553857+00:00", + "phase": "implement" + }, + { + "id": "820eb277-65b0-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=101164 util=0.10 cache_hit=0.99 decision=no_warm_session", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:16:44.560157+00:00", + "phase": "implement" + }, + { + "id": "69a00aec-50f0-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "14th spawn, worktree still unmounted; cq-1 still unresolved (pending_decisions=1). Cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:16:46.520203+00:00", + "phase": "implement" + }, + { + "id": "d319f05e-cbb0-48", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=178739 util=0.18 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:16:51.902987+00:00", + "phase": "implement" + }, + { + "id": "a69028db-2b13-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:16:57.244378+00:00", + "phase": "implement" + }, + { + "id": "cd798d97-a132-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:17:06.528643+00:00", + "phase": "implement" + }, + { + "id": "3750a310-a3be-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "15th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14 (remount or park). Coder proposal standing at 3a85636d6. Ready on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:17:15.410266+00:00", + "phase": "implement" + }, + { + "id": "fe78c284-11a0-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=180580 util=0.18 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:17:21.405783+00:00", + "phase": "implement" + }, + { + "id": "7b3ebca2-f2ae-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:17:34.245964+00:00", + "phase": "implement" + }, + { + "id": "59cef516-f65e-40", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_contract for tester", + "body": "Contract review of tester's slice-3 verification proposal (verification-only, no file changes) at commit 3a85636d6. Producer claims task-3-7 (\"Green the boundary: make lint + make test-all green; no behavior change\") and its acceptance criteria are met: (1) Allowlist drop verified against the proposal SHA \u2014 scripts/file-size-allowlist.yaml files: map now holds ONLY orchestrator/routes/pipelines.py (gateway.py entry removed, 2 lines). (2) Ratchet exit 0 attested with largest submodule _git_ops.py 1357L/57.9KB, all under 1500L/100KB caps. (3) Pure-refactor / no-behavior-change corroborated structurally: the --stat diff touches only gateway/gateway/*, the coder\u2192documenter seam-table md, and the allowlist \u2014 it touches NEITHER gateway/git_client/ NOR gateway/worktree_manager/, which independently validates the tester's proof that the 7 failures + 6 errors are pre-existing sandbox-environmental (git-init-blocked / port-in-use), not refactor-induced. (4) Import/route parity attested (import gateway.gateway resolves, Flask app present, 61 routes). Tester attestation carries checks_passed=[lint,security,test], tests_run=3509, tests_execution_blocked=false, matching the coder attestation. task-3-7 satisfied.", + "metadata": { + "payload": { + "artifact_references": [ + "scripts/file-size-allowlist.yaml", + "gateway/gateway/__init__.py", + "3a85636d683bdf48308bc3b35db1823e3e76a6e0" + ], + "reason": "Contract review of tester's slice-3 verification proposal (verification-only, no file changes) at commit 3a85636d6. Producer claims task-3-7 (\"Green the boundary: make lint + make test-all green; no behavior change\") and its acceptance criteria are met: (1) Allowlist drop verified against the proposal SHA \u2014 scripts/file-size-allowlist.yaml files: map now holds ONLY orchestrator/routes/pipelines.py (gateway.py entry removed, 2 lines). (2) Ratchet exit 0 attested with largest submodule _git_ops.py 1357L/57.9KB, all under 1500L/100KB caps. (3) Pure-refactor / no-behavior-change corroborated structurally: the --stat diff touches only gateway/gateway/*, the coder\u2192documenter seam-table md, and the allowlist \u2014 it touches NEITHER gateway/git_client/ NOR gateway/worktree_manager/, which independently validates the tester's proof that the 7 failures + 6 errors are pre-existing sandbox-environmental (git-init-blocked / port-in-use), not refactor-induced. (4) Import/route parity attested (import gateway.gateway resolves, Flask app present, 61 routes). Tester attestation carries checks_passed=[lint,security,test], tests_run=3509, tests_execution_blocked=false, matching the coder attestation. task-3-7 satisfied.", + "ack_version": 1, + "attestation": { + "tasks_verified": [ + "task-3-7" + ], + "verified_commit": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "allowlist_files_map": [ + "orchestrator/routes/pipelines.py" + ], + "no_behavior_change_evidence": "diff --stat touches only gateway/gateway/*, seam-table md, allowlist; git_client/ and worktree_manager/ byte-identical to baseline", + "checks_reviewed": [ + "lint", + "test", + "security" + ] + } + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:17:51.170421+00:00", + "phase": "implement" + }, + { + "id": "d4575ff2-4af0-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=75078 util=0.08 cache_hit=0.94 decision=no_warm_session", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:18:03.559188+00:00", + "phase": "implement" + }, + { + "id": "b939e982-a0ef-46", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_code_holistic for tester", + "body": "ACK (reviewer_code_holistic) of tester slice-3 verification @ 3a85636d6 \u2014 gateway/gateway.py \u2192 gateway/gateway/ sub-package. Tester produced no file changes; verdict is a verification attestation, independently confirmed against the SHA. (1) ROUTE PARITY \u2014 49 live @app.route decorators in barrel == 49 in pre-split baseline 9079e9c02; each submodule's single @app.route grep hit is a docstring line, not a live decorator \u2192 no double-registration. (2) DEF PARITY \u2014 all 120 baseline top-level defs present in the split union; only addition is `_b` (documented call-time seam accessor); no handler dropped/renamed \u2192 behavior-preserving. (3) SIZE CAPS \u2014 every module under 1500 lines / 100KB (largest _git_ops.py 1357 lines / 57.9KB; barrel 1342 lines). (4) ALLOWLIST \u2014 file-size-allowlist.yaml correctly drops gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py. (5) SEAM MECHANISM \u2014 _b() resolves sys.modules['gateway.gateway'] at call time so patch('gateway.gateway.X') stays effective; barrel imports all submodules + per-symbol re-exports + __all__. (6) TEST INTEGRITY \u2014 conftest.py change is a package-loader adaptation (spec_from_file_location + submodule_search_locations, dual-registering gateway and gateway.gateway), NOT assertion weakening. (7) FAILING-TEST PROVENANCE \u2014 git diff 6be7c255d..3a85636d6 -- gateway/git_client/ gateway/worktree_manager/ is empty (byte-identical), proving the git-init/port-bind failures are pre-existing sandbox-env, not refactor-induced. Tester attestation (3502 passed; 7 fail + 6 err all environmental) matches my verification. Holistically sound, no blocking concerns.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_helpers.py", + "gateway/gateway/_confluence.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_sessions.py", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "ACK (reviewer_code_holistic) of tester slice-3 verification @ 3a85636d6 \u2014 gateway/gateway.py \u2192 gateway/gateway/ sub-package. Tester produced no file changes; verdict is a verification attestation, independently confirmed against the SHA. (1) ROUTE PARITY \u2014 49 live @app.route decorators in barrel == 49 in pre-split baseline 9079e9c02; each submodule's single @app.route grep hit is a docstring line, not a live decorator \u2192 no double-registration. (2) DEF PARITY \u2014 all 120 baseline top-level defs present in the split union; only addition is `_b` (documented call-time seam accessor); no handler dropped/renamed \u2192 behavior-preserving. (3) SIZE CAPS \u2014 every module under 1500 lines / 100KB (largest _git_ops.py 1357 lines / 57.9KB; barrel 1342 lines). (4) ALLOWLIST \u2014 file-size-allowlist.yaml correctly drops gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py. (5) SEAM MECHANISM \u2014 _b() resolves sys.modules['gateway.gateway'] at call time so patch('gateway.gateway.X') stays effective; barrel imports all submodules + per-symbol re-exports + __all__. (6) TEST INTEGRITY \u2014 conftest.py change is a package-loader adaptation (spec_from_file_location + submodule_search_locations, dual-registering gateway and gateway.gateway), NOT assertion weakening. (7) FAILING-TEST PROVENANCE \u2014 git diff 6be7c255d..3a85636d6 -- gateway/git_client/ gateway/worktree_manager/ is empty (byte-identical), proving the git-init/port-bind failures are pre-existing sandbox-env, not refactor-induced. Tester attestation (3502 passed; 7 fail + 6 err all environmental) matches my verification. Holistically sound, no blocking concerns.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:18:54.216586+00:00", + "phase": "implement" + }, + { + "id": "20ac0564-fcb1-44", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=87079 util=0.09 cache_hit=0.98 decision=no_warm_session", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:19:04.834993+00:00", + "phase": "implement" + }, + { + "id": "7d781459-ad69-49", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:47:02.307049+00:00", + "phase": "implement" + }, + { + "id": "f72eeabd-2606-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:47:09.888885+00:00", + "phase": "implement" + }, + { + "id": "8024c669-04be-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "16th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:47:15.874161+00:00", + "phase": "implement" + }, + { + "id": "3539dcd5-c2f0-42", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=182194 util=0.18 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T06:47:20.749628+00:00", + "phase": "implement" + }, + { + "id": "3fb36542-a6cd-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:17:03.314742+00:00", + "phase": "implement" + }, + { + "id": "c1b76479-2aba-41", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:17:11.138699+00:00", + "phase": "implement" + }, + { + "id": "017c31ea-8530-48", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "17th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Reviewers now all REVIEWING coder+tester proposals (no NACKs); cq-1 still unresolved. Documenter seam row still required for task-3-5. Escalated high 06:14. Ready to author + propose on 3a85636d6 the instant the mount returns.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:17:28.393571+00:00", + "phase": "implement" + }, + { + "id": "f05ffa77-fecf-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=185191 util=0.19 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:17:33.718995+00:00", + "phase": "implement" + }, + { + "id": "d5f9ac9a-7abd-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:21:09.887587+00:00", + "phase": "implement" + }, + { + "id": "8fdf4e09-7159-40", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:21:10.979247+00:00", + "phase": "implement" + }, + { + "id": "33dc5564-0ee9-49", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:21:18.514456+00:00", + "phase": "implement" + }, + { + "id": "b730d188-f97d-41", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:21:22.303043+00:00", + "phase": "implement" + }, + { + "id": "02178473-3a61-41", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "18th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14 (remount or park). Coder proposal standing at 3a85636d6. Ready on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:21:24.070699+00:00", + "phase": "implement" + }, + { + "id": "f16f899c-b12c-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=186966 util=0.19 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:21:29.855125+00:00", + "phase": "implement" + }, + { + "id": "a1b91ba4-a4b0-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:21:37.344306+00:00", + "phase": "implement" + }, + { + "id": "ef516ac0-ebe3-40", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:21:43.937623+00:00", + "phase": "implement" + }, + { + "id": "93aa529c-f7b7-45", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "19th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:21:48.424450+00:00", + "phase": "implement" + }, + { + "id": "bac0af44-973e-45", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=188524 util=0.19 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:21:53.372219+00:00", + "phase": "implement" + }, + { + "id": "abacc651-f987-49", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:21:58.932012+00:00", + "phase": "implement" + }, + { + "id": "f88e3ab0-e7cd-46", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:22:06.269248+00:00", + "phase": "implement" + }, + { + "id": "e29e9123-46a3-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "20th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:22:13.374776+00:00", + "phase": "implement" + }, + { + "id": "2461df4c-0988-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=190266 util=0.19 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:22:19.296016+00:00", + "phase": "implement" + }, + { + "id": "b94e9bbb-0423-43", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from coder", + "body": "[Re-propose to re-register coder \u2014 the consensus matrix keeps resetting coder/tester/documenter to proposal_version==0 across the infra churn; content is byte-identical and unchanged at commit 3a85636d6, already pushed.] Decompose gateway/gateway.py (10,648 lines/419KB, over byte cap) into gateway/gateway/ sub-package: barrel (1342 lines) + 14 submodules, all under 1500-line/100KB caps. Pure refactor, bodies AST-identical. @app.route decorators stay on thin barrel wrappers delegating to _ impls; barrel re-exports every symbol + __all__ so patch(\"gateway.gateway.X\")/patch.object/monkeypatch.setattr resolve unchanged; seams via _b() call-time barrel lookup; _BarrelLogger proxy; module-singleton seams (subprocess/time/open) stay barrel attrs. Allowlist entry dropped. Verified: gateway/tests 3342 passed (7 failed+6 errors all pre-existing sandbox-env); tester independently GREEN (3502 passed, bandit clean, ratchet 0); ruff clean; reviewer_concurrency + reviewer_contract ACKed. Two commits 9079e9c02 (baseline) + 3a85636d6 (extraction).", + "metadata": { + "payload": { + "summary": "[Re-propose to re-register coder \u2014 the consensus matrix keeps resetting coder/tester/documenter to proposal_version==0 across the infra churn; content is byte-identical and unchanged at commit 3a85636d6, already pushed.] Decompose gateway/gateway.py (10,648 lines/419KB, over byte cap) into gateway/gateway/ sub-package: barrel (1342 lines) + 14 submodules, all under 1500-line/100KB caps. Pure refactor, bodies AST-identical. @app.route decorators stay on thin barrel wrappers delegating to _ impls; barrel re-exports every symbol + __all__ so patch(\"gateway.gateway.X\")/patch.object/monkeypatch.setattr resolve unchanged; seams via _b() call-time barrel lookup; _BarrelLogger proxy; module-singleton seams (subprocess/time/open) stay barrel attrs. Allowlist entry dropped. Verified: gateway/tests 3342 passed (7 failed+6 errors all pre-existing sandbox-env); tester independently GREEN (3502 passed, bandit clean, ratchet 0); ruff clean; reviewer_concurrency + reviewer_contract ACKed. Two commits 9079e9c02 (baseline) + 3a85636d6 (extraction).", + "attestation": { + "commit_shas": [ + "9079e9c02b17a74a9c711265b3a5347f15f101ca", + "3a85636d683bdf48308bc3b35db1823e3e76a6e0" + ], + "files_changed": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "tests_run": 3407, + "checks_passed": [ + "ruff-check", + "ruff-format", + "file-size-ratchet", + "gateway-tests" + ], + "tests_execution_blocked": false + }, + "artifacts": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "risk_considered": "Content risk fully reviewed (concurrency + contract ACK, tester GREEN). The live blocker is infra, not content: the consensus matrix does not persist producer proposal_version (resets to 0 across spawns), so convergence is gated on operator infra repair, not on coder work.", + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "files_changed": [ + "gateway/gateway/__init__.py", + "gateway/gateway/__main__.py", + "gateway/gateway/_helpers.py", + "gateway/gateway/_health.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_git_execute.py", + "gateway/gateway/_gh_ops.py", + "gateway/gateway/_gh_execute.py", + "gateway/gateway/_jira.py", + "gateway/gateway/_jira_writes.py", + "gateway/gateway/_confluence.py", + "gateway/gateway/_worktree.py", + "gateway/gateway/_sessions.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "tests_run": [], + "tasks_satisfied": [ + "task-3-1", + "task-3-2", + "task-3-3", + "task-3-4", + "task-3-5", + "task-3-6", + "task-3-7" + ], + "no_changes_needed": false, + "no_changes_reason": "" + }, + "version": 1, + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:22:50.751237+00:00", + "phase": "implement" + }, + { + "id": "7f767d78-b986-49", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:22:57.866769+00:00", + "phase": "implement" + }, + { + "id": "13bfb7ca-6b8a-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:22:57.876294+00:00", + "phase": "implement" + }, + { + "id": "c76e34fe-99cc-42", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:22:59.730768+00:00", + "phase": "implement" + }, + { + "id": "aababc59-c3a2-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:01.625153+00:00", + "phase": "implement" + }, + { + "id": "235a08aa-2cc7-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:03.415990+00:00", + "phase": "implement" + }, + { + "id": "cf804fcd-64b8-49", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:05.243314+00:00", + "phase": "implement" + }, + { + "id": "366ead5a-0c8f-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:05.806707+00:00", + "phase": "implement" + }, + { + "id": "6e7010bf-734e-41", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:06.296418+00:00", + "phase": "implement" + }, + { + "id": "a37e0412-e9e3-40", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:06.763596+00:00", + "phase": "implement" + }, + { + "id": "958050ff-4c26-42", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:07.763558+00:00", + "phase": "implement" + }, + { + "id": "f1de791e-27b0-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:14.193022+00:00", + "phase": "implement" + }, + { + "id": "21ef1256-5916-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:14.615024+00:00", + "phase": "implement" + }, + { + "id": "045230d6-c4bd-40", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:16.291610+00:00", + "phase": "implement" + }, + { + "id": "cf9c75ec-cdc2-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:17.105275+00:00", + "phase": "implement" + }, + { + "id": "42a7df8b-6b48-40", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "21st spawn, worktree still unmounted; cq-1 still unresolved (pending_decisions=1). Cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:19.501399+00:00", + "phase": "implement" + }, + { + "id": "c16551ab-45e5-45", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=192139 util=0.19 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:27.048418+00:00", + "phase": "implement" + }, + { + "id": "408242f8-4f6e-43", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:27.498869+00:00", + "phase": "implement" + }, + { + "id": "4f24bcb4-c238-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_code for coder", + "body": "Re-ACK of coder v1 at commit 3a85636 (unchanged, byte-identical re-propose to re-register in the reset consensus matrix \u2014 infra churn, not a content change). My prior structural review stands: (1) all 14 submodules + barrel under 1500-line/100KB caps (largest _git_ops.py 1357 ln/57.9KB); (2) seam machinery sound \u2014 _b() resolves sys.modules['gateway.gateway'] or 'gateway' and every seam getter/validator/helper routes through _b(). at call time (46 uses _git_ops, 54 _confluence...), so patch('gateway.gateway.') stays effective; _BarrelLogger proxies to _b().logger; (3) barrel explicit per-symbol re-exports + __all__, @app.route wrappers delegate to submodule impls; (4) __main__.py + 'python3 -m gateway' entrypoint + Dockerfile COPY gateway/gateway/ consistent; (5) allowlist trimmed to only orchestrator/routes/pipelines.py; (6) conftest spec_from_file_location registers both gateway and gateway.gateway per existing loader pattern. No correctness or reuse issues.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/gateway/_helpers.py", + "gateway/gateway/__main__.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "Re-ACK of coder v1 at commit 3a85636 (unchanged, byte-identical re-propose to re-register in the reset consensus matrix \u2014 infra churn, not a content change). My prior structural review stands: (1) all 14 submodules + barrel under 1500-line/100KB caps (largest _git_ops.py 1357 ln/57.9KB); (2) seam machinery sound \u2014 _b() resolves sys.modules['gateway.gateway'] or 'gateway' and every seam getter/validator/helper routes through _b(). at call time (46 uses _git_ops, 54 _confluence...), so patch('gateway.gateway.') stays effective; _BarrelLogger proxies to _b().logger; (3) barrel explicit per-symbol re-exports + __all__, @app.route wrappers delegate to submodule impls; (4) __main__.py + 'python3 -m gateway' entrypoint + Dockerfile COPY gateway/gateway/ consistent; (5) allowlist trimmed to only orchestrator/routes/pipelines.py; (6) conftest spec_from_file_location registers both gateway and gateway.gateway per existing loader pattern. No correctness or reuse issues.", + "ack_version": 1, + "attestation": { + "files_reviewed": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/gateway/_helpers.py", + "gateway/gateway/__main__.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "issues_found": 0 + } + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:34.069771+00:00", + "phase": "implement" + }, + { + "id": "4fcc06aa-5b46-48", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:38.302955+00:00", + "phase": "implement" + }, + { + "id": "92eb7158-af66-43", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_code_holistic for coder", + "body": "ACK (reviewer_code_holistic) of coder slice-3 @ 3a85636d6 \u2014 gateway/gateway.py (10,648 lines, byte-cap outlier) \u2192 gateway/gateway/ barrel + 14 submodules. Holistically verified pure, behavior-preserving refactor. (1) ROUTE PARITY \u2014 49 live @app.route decorators in the barrel == 49 in pre-split baseline 9079e9c02; the single @app.route grep hit per submodule is a docstring line, not a live decorator \u2192 no double-registration. (2) DEF PARITY \u2014 all 120 baseline top-level defs present in the split union; only addition is `_b` (documented call-time seam accessor). No handler dropped/renamed. (3) SIZE CAPS \u2014 every module under 1500 lines / 100KB (largest _git_ops.py 1357 lines / 57.9KB; barrel 1342 lines). (4) ALLOWLIST \u2014 file-size-allowlist.yaml correctly drops gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py. (5) SEAM MECHANISM \u2014 _b() resolves sys.modules['gateway.gateway'] at call time so patch('gateway.gateway.X') stays effective; barrel imports all submodules + per-symbol re-exports + __all__; _BarrelLogger proxy + barrel-attribute module singletons preserved. (6) CONTAINER PACKAGING \u2014 Dockerfile adds `COPY gateway/gateway/ ./gateway/` per the established non-recursive-glob pattern (git_client/worktree_manager); BOTH entrypoint.sh launch branches switch `python3 gateway.py` \u2192 `python3 -m gateway`; __main__.py is a thin shim to barrel main(). No stale gateway.py launch left. (7) TEST INTEGRITY \u2014 conftest.py change is a package-loader adaptation (spec_from_file_location + submodule_search_locations, dual-registering gateway and gateway.gateway), not assertion weakening. (8) FAILING-TEST PROVENANCE \u2014 git diff 6be7c255d..3a85636d6 -- gateway/git_client/ gateway/worktree_manager/ is empty (byte-identical), proving the git-init/port-bind failures are pre-existing sandbox-env, not refactor-induced. Only honest gap (coder-flagged, non-blocking): in-image docker smoke check unrunnable in sandbox (no docker); packaging follows the proven slice-11/12 pattern. Holistically sound, no blocking concerns.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/gateway/__main__.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "ACK (reviewer_code_holistic) of coder slice-3 @ 3a85636d6 \u2014 gateway/gateway.py (10,648 lines, byte-cap outlier) \u2192 gateway/gateway/ barrel + 14 submodules. Holistically verified pure, behavior-preserving refactor. (1) ROUTE PARITY \u2014 49 live @app.route decorators in the barrel == 49 in pre-split baseline 9079e9c02; the single @app.route grep hit per submodule is a docstring line, not a live decorator \u2192 no double-registration. (2) DEF PARITY \u2014 all 120 baseline top-level defs present in the split union; only addition is `_b` (documented call-time seam accessor). No handler dropped/renamed. (3) SIZE CAPS \u2014 every module under 1500 lines / 100KB (largest _git_ops.py 1357 lines / 57.9KB; barrel 1342 lines). (4) ALLOWLIST \u2014 file-size-allowlist.yaml correctly drops gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py. (5) SEAM MECHANISM \u2014 _b() resolves sys.modules['gateway.gateway'] at call time so patch('gateway.gateway.X') stays effective; barrel imports all submodules + per-symbol re-exports + __all__; _BarrelLogger proxy + barrel-attribute module singletons preserved. (6) CONTAINER PACKAGING \u2014 Dockerfile adds `COPY gateway/gateway/ ./gateway/` per the established non-recursive-glob pattern (git_client/worktree_manager); BOTH entrypoint.sh launch branches switch `python3 gateway.py` \u2192 `python3 -m gateway`; __main__.py is a thin shim to barrel main(). No stale gateway.py launch left. (7) TEST INTEGRITY \u2014 conftest.py change is a package-loader adaptation (spec_from_file_location + submodule_search_locations, dual-registering gateway and gateway.gateway), not assertion weakening. (8) FAILING-TEST PROVENANCE \u2014 git diff 6be7c255d..3a85636d6 -- gateway/git_client/ gateway/worktree_manager/ is empty (byte-identical), proving the git-init/port-bind failures are pre-existing sandbox-env, not refactor-induced. Only honest gap (coder-flagged, non-blocking): in-image docker smoke check unrunnable in sandbox (no docker); packaging follows the proven slice-11/12 pattern. Holistically sound, no blocking concerns.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:39.066176+00:00", + "phase": "implement" + }, + { + "id": "d70cd380-6359-49", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_security for coder", + "body": "No security regression (re-ACK, same SHA 3a85636d/v1, verdict unchanged). Pure decomposition of gateway/gateway.py into a barrel + 14 submodules; security-critical bodies verified AST-identical to the pure-move baseline (9079e9c0, byte-identical to origin/main) modulo the documented _b() seam-accessor rewrite (semantically equivalent \u2014 _b() returns the barrel, resolving patched seams at call time). Verified preserved: Anthropic + LiteLLM/upstream credential injection, launcher-secret auth (_verify_orchestrator_transition_auth), private-mode tool blocking (BLOCKED_TOOLS_PRIVATE_MODE + _filter_blocked_tools identical), ANTHROPIC_BLOCKED_HEADERS forwarding blocklist, attribution sanitization, audit_log, Jira/Confluence write validators + space allowlist. Container still gosu-drops to HOST_UID:GID; python3 -m gateway launch equivalent. allowlist drop safe (files under cap). The except OSError, subprocess.TimeoutExpired tuple-catch parses fine and is byte-identical to origin/main (pre-existing). No new secret logging, endpoints, auth bypass, or tool-block weakening.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "No security regression (re-ACK, same SHA 3a85636d/v1, verdict unchanged). Pure decomposition of gateway/gateway.py into a barrel + 14 submodules; security-critical bodies verified AST-identical to the pure-move baseline (9079e9c0, byte-identical to origin/main) modulo the documented _b() seam-accessor rewrite (semantically equivalent \u2014 _b() returns the barrel, resolving patched seams at call time). Verified preserved: Anthropic + LiteLLM/upstream credential injection, launcher-secret auth (_verify_orchestrator_transition_auth), private-mode tool blocking (BLOCKED_TOOLS_PRIVATE_MODE + _filter_blocked_tools identical), ANTHROPIC_BLOCKED_HEADERS forwarding blocklist, attribution sanitization, audit_log, Jira/Confluence write validators + space allowlist. Container still gosu-drops to HOST_UID:GID; python3 -m gateway launch equivalent. allowlist drop safe (files under cap). The except OSError, subprocess.TimeoutExpired tuple-catch parses fine and is byte-identical to origin/main (pre-existing). No new secret logging, endpoints, auth bypass, or tool-block weakening.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:39.707905+00:00", + "phase": "implement" + }, + { + "id": "085873e5-28cf-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_concurrency for coder", + "body": "Re-ACK coder@v1 (SHA 3a85636, unchanged; re-registering in reset consensus matrix). Concurrency verdict unchanged and already verified: sole lock _worktree_prune_lock is a single shared Lock instance (identity-verified; _b() resolves to same object) so prune mutual exclusion holds; acquire(timeout=60)->409 with finally-release, no leak; both _server.py threads daemon fire-and-forget; session pruner + waitress threads unchanged. Pure refactor, AST-identical bodies. No concurrency regression.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_worktree.py", + "gateway/gateway/_server.py" + ], + "reason": "Re-ACK coder@v1 (SHA 3a85636, unchanged; re-registering in reset consensus matrix). Concurrency verdict unchanged and already verified: sole lock _worktree_prune_lock is a single shared Lock instance (identity-verified; _b() resolves to same object) so prune mutual exclusion holds; acquire(timeout=60)->409 with finally-release, no leak; both _server.py threads daemon fire-and-forget; session pruner + waitress threads unchanged. Pure refactor, AST-identical bodies. No concurrency regression.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:41.222163+00:00", + "phase": "implement" + }, + { + "id": "e6e8c5b6-7b1e-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=103723 util=0.10 cache_hit=0.98 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:42.060562+00:00", + "phase": "implement" + }, + { + "id": "9a41d7b6-4ac6-46", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=562181 util=0.56 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:44.052384+00:00", + "phase": "implement" + }, + { + "id": "f5584a6a-d469-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:46.454973+00:00", + "phase": "implement" + }, + { + "id": "2db1a32b-8fef-44", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=88725 util=0.09 cache_hit=0.98 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:46.737476+00:00", + "phase": "implement" + }, + { + "id": "422035d2-6c91-42", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=94724 util=0.09 cache_hit=0.98 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:46.780937+00:00", + "phase": "implement" + }, + { + "id": "e59a05fa-bd53-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=92720 util=0.09 cache_hit=0.98 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:48.952574+00:00", + "phase": "implement" + }, + { + "id": "eec10fa2-3415-43", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "22nd spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:52.573954+00:00", + "phase": "implement" + }, + { + "id": "c0daad58-de68-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=193856 util=0.19 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:23:58.513367+00:00", + "phase": "implement" + }, + { + "id": "49d6ee29-9dbb-48", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from tester", + "body": "[Re-propose to re-register tester in the consensus matrix \u2014 proposal_version keeps resetting to 0 across the infra churn (mirrors coder's 07:22 re-register); content byte-identical, commit unchanged at 3a85636d6 = current HEAD, git status clean. No re-verification of a changed delta needed.] Tester verification of slice-3 (gateway/gateway.py \u2192 gateway/gateway/ sub-package). Boundary GREEN, pure refactor. Import parity: `import gateway.gateway` resolves, Flask app present, 61 routes registered identically. LINT: ruff check + ruff format clean; file-size ratchet exit 0 with allowlist files: map now holding ONLY orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 lines/57.9KB \u2014 all under 1500-line/100KB caps. SECURITY: bandit -ll on gateway/gateway \u2192 No issues identified. TEST: gateway/tests/+tests/gateway/ = 3502 passed, 16 skipped, 7 failed, 6 errors \u2014 all 7 failures + 6 errors PROVEN sandbox-environmental (3 TestHealthCheckServer: OSError Address already in use; git_client_base_branch + worktree_manager: \"git init is not supported in the container\"), NOT refactor-induced: diff 6be7c255d..3a85636d6 leaves gateway/git_client/ + gateway/worktree_manager/ source byte-identical so their failures cannot originate here; counts match coder attestation exactly. Patch seams preserved (barrel re-export + __all__ + conftest spec-load). No test-patch rewrites needed. Tester produced no file changes \u2014 deliverable is the verification verdict.", + "metadata": { + "payload": { + "summary": "[Re-propose to re-register tester in the consensus matrix \u2014 proposal_version keeps resetting to 0 across the infra churn (mirrors coder's 07:22 re-register); content byte-identical, commit unchanged at 3a85636d6 = current HEAD, git status clean. No re-verification of a changed delta needed.] Tester verification of slice-3 (gateway/gateway.py \u2192 gateway/gateway/ sub-package). Boundary GREEN, pure refactor. Import parity: `import gateway.gateway` resolves, Flask app present, 61 routes registered identically. LINT: ruff check + ruff format clean; file-size ratchet exit 0 with allowlist files: map now holding ONLY orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 lines/57.9KB \u2014 all under 1500-line/100KB caps. SECURITY: bandit -ll on gateway/gateway \u2192 No issues identified. TEST: gateway/tests/+tests/gateway/ = 3502 passed, 16 skipped, 7 failed, 6 errors \u2014 all 7 failures + 6 errors PROVEN sandbox-environmental (3 TestHealthCheckServer: OSError Address already in use; git_client_base_branch + worktree_manager: \"git init is not supported in the container\"), NOT refactor-induced: diff 6be7c255d..3a85636d6 leaves gateway/git_client/ + gateway/worktree_manager/ source byte-identical so their failures cannot originate here; counts match coder attestation exactly. Patch seams preserved (barrel re-export + __all__ + conftest spec-load). No test-patch rewrites needed. Tester produced no file changes \u2014 deliverable is the verification verdict.", + "attestation": { + "tests_run": 3509, + "tests_execution_blocked": false, + "checks_passed": [ + "lint", + "security", + "test" + ], + "check_detail": { + "lint": "ruff check + ruff format clean; check-file-sizes.py exit 0 (allowlist=pipelines.py only)", + "security": "bandit -ll on gateway/gateway: No issues identified", + "test": "gateway/tests/+tests/gateway/: 3502 passed, 16 skipped; 7 failed + 6 errors all sandbox-env (git-init blocked / port-in-use), source byte-identical to baseline" + }, + "verified_commit": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "re_register": true + }, + "artifacts": [ + ".egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md", + "gateway/gateway/__init__.py", + "scripts/file-size-allowlist.yaml", + "3a85636d683bdf48308bc3b35db1823e3e76a6e0" + ], + "risk_considered": "Pure-refactor risk is import/patch-seam breakage + container-packaging drift; verified independently (61-route parity, ratchet green with only pipelines.py, git_client/worktree_manager byte-identical so their env failures are pre-existing). This re-propose is a matrix re-registration only \u2014 no content changed since the ACKed round. FS working-tree overlay is transiently detached this invocation (scripts/ + .egg-state/ not visible) but git object store is intact and confirms HEAD==3a85636d6 unchanged; my earlier tool runs (ruff/bandit/pytest/check-file-sizes) executed against a healthy tree at this same SHA. make lint/test-all not runnable via `make` (venv re-sync needs blocked pip egress); docker image smoke-check not runnable (no docker) \u2014 same environmental limits the coder noted.", + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "files_changed": [], + "tests_run": [], + "tasks_satisfied": [ + "task-3-7" + ], + "no_changes_needed": false, + "no_changes_reason": "" + }, + "version": 1, + "commit_sha": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:05.550848+00:00", + "phase": "implement" + }, + { + "id": "fedf10f9-4c38-40", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:06.875874+00:00", + "phase": "implement" + }, + { + "id": "12924783-b6d6-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:07.734065+00:00", + "phase": "implement" + }, + { + "id": "a98138fd-39ab-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:08.364212+00:00", + "phase": "implement" + }, + { + "id": "255a6201-bd65-46", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:09.146775+00:00", + "phase": "implement" + }, + { + "id": "9cdd8d4e-8326-42", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:09.803429+00:00", + "phase": "implement" + }, + { + "id": "bf40df19-8761-45", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:10.443947+00:00", + "phase": "implement" + }, + { + "id": "3aeee9a9-4275-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:14.292636+00:00", + "phase": "implement" + }, + { + "id": "fc8b48fb-c2c0-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:17.795123+00:00", + "phase": "implement" + }, + { + "id": "75b78bb7-bf99-42", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:18.110651+00:00", + "phase": "implement" + }, + { + "id": "c3f60552-f683-45", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:19.468537+00:00", + "phase": "implement" + }, + { + "id": "ae369172-76e6-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=115691 util=0.12 cache_hit=0.95 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:19.989359+00:00", + "phase": "implement" + }, + { + "id": "a84f86dc-e050-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_security for tester", + "body": "No security-relevant coverage change (re-ACK, same SHA 3a85636d/v1, verdict unchanged). The only test-side delta is gateway/tests/conftest.py, a loader mechanism swap (single-file _load_module_with_replaced_imports \u2192 package spec with submodule_search_locations). It preserves dual registration of the package under both `gateway` and `gateway.gateway`, so security-relevant patch targets (patch(\"gateway.gateway.get_session_manager\"), get_launcher_secret, credential managers) continue resolving to the barrel. No test functions, assertions, or patch targets removed; no security seam weakened. Full gateway/tests suite passes (3342 passed; remaining failures pre-existing sandbox-env, identical to baseline).", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/tests/conftest.py", + "gateway/gateway/__init__.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "No security-relevant coverage change (re-ACK, same SHA 3a85636d/v1, verdict unchanged). The only test-side delta is gateway/tests/conftest.py, a loader mechanism swap (single-file _load_module_with_replaced_imports \u2192 package spec with submodule_search_locations). It preserves dual registration of the package under both `gateway` and `gateway.gateway`, so security-relevant patch targets (patch(\"gateway.gateway.get_session_manager\"), get_launcher_secret, credential managers) continue resolving to the barrel. No test functions, assertions, or patch targets removed; no security seam weakened. Full gateway/tests suite passes (3342 passed; remaining failures pre-existing sandbox-env, identical to baseline).", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:21.212387+00:00", + "phase": "implement" + }, + { + "id": "29390cbe-6e8c-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_concurrency for tester", + "body": "Re-ACK tester@v1 (SHA 3a85636, unchanged; re-registering in reset consensus matrix). Concurrency verdict unchanged: tester made no code changes (verification verdict only). gateway/tests 3502 passed exercises barrel/seam resolution (confirms _worktree_prune_lock + patch seams resolve under test). Only concurrency-surface failures are TestHealthCheckServer -> OSError [Errno 98] Address already in use (environmental port-bind); health-server thread body AST-identical so cannot mask a threading regression. Attestation soundly rules out a concurrency regression.", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md", + "gateway/gateway/__init__.py" + ], + "reason": "Re-ACK tester@v1 (SHA 3a85636, unchanged; re-registering in reset consensus matrix). Concurrency verdict unchanged: tester made no code changes (verification verdict only). gateway/tests 3502 passed exercises barrel/seam resolution (confirms _worktree_prune_lock + patch seams resolve under test). Only concurrency-surface failures are TestHealthCheckServer -> OSError [Errno 98] Address already in use (environmental port-bind); health-server thread body AST-identical so cannot mask a threading regression. Attestation soundly rules out a concurrency regression.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:21.358862+00:00", + "phase": "implement" + }, + { + "id": "a2e64ea8-7ef7-40", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_code for tester", + "body": "Re-ACK of tester v1 at commit 3a85636 (unchanged, byte-identical re-propose to re-register in the reset consensus matrix \u2014 infra churn, not a content change). Tester's verification verdict remains sound and consistent with my independent structural review: file-size ratchet green with allowlist holding only orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 ln/57.9KB under caps; seams intact (re-exports + _b() routing) so no test-patch rewrites needed; the 7 failures + 6 errors are credibly sandbox-environmental (port-in-use TestHealthCheckServer OSError 98; git-init-blocked git_client/worktree_manager, whose source the refactor diff leaves byte-identical). Attestation well-formed (tests_run 3509, checks_passed lint/security/test, tests_execution_blocked false). No code issues.", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md", + "gateway/gateway/__init__.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "Re-ACK of tester v1 at commit 3a85636 (unchanged, byte-identical re-propose to re-register in the reset consensus matrix \u2014 infra churn, not a content change). Tester's verification verdict remains sound and consistent with my independent structural review: file-size ratchet green with allowlist holding only orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 ln/57.9KB under caps; seams intact (re-exports + _b() routing) so no test-patch rewrites needed; the 7 failures + 6 errors are credibly sandbox-environmental (port-in-use TestHealthCheckServer OSError 98; git-init-blocked git_client/worktree_manager, whose source the refactor diff leaves byte-identical). Attestation well-formed (tests_run 3509, checks_passed lint/security/test, tests_execution_blocked false). No code issues.", + "ack_version": 1, + "attestation": { + "files_reviewed": [ + ".egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md", + "gateway/gateway/__init__.py", + "scripts/file-size-allowlist.yaml" + ], + "issues_found": 0 + } + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:21.486250+00:00", + "phase": "implement" + }, + { + "id": "876fd3bf-1f01-48", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "23rd spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount.", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:21.538699+00:00", + "phase": "implement" + }, + { + "id": "ea0cd5a0-bfc4-4e", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:21.676788+00:00", + "phase": "implement" + }, + { + "id": "42ac08d2-45d2-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:21.857242+00:00", + "phase": "implement" + }, + { + "id": "64cb34f3-0044-41", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:21.918150+00:00", + "phase": "implement" + }, + { + "id": "90bee168-d604-47", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:25.745936+00:00", + "phase": "implement" + }, + { + "id": "8b098cd3-436b-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=106135 util=0.11 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:25.850979+00:00", + "phase": "implement" + }, + { + "id": "15f77d92-f230-46", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=91080 util=0.09 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:26.505675+00:00", + "phase": "implement" + }, + { + "id": "8f94aa44-24d0-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=96762 util=0.10 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:26.750087+00:00", + "phase": "implement" + }, + { + "id": "45df62d6-fc88-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=195413 util=0.20 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:27.145837+00:00", + "phase": "implement" + }, + { + "id": "0cd14076-e021-40", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_contract for coder", + "body": "Contract review of coder's slice-3 implementation (gateway/gateway.py -> gateway/gateway/ sub-package) at commit 3a85636d6 (baseline 9079e9c02 + extraction 3a85636d6). All claimed tasks task-3-1..task-3-7 verified against acceptance criteria:\\n\\n- task-3-3 (routes convention / non-negotiable #8): VERIFIED DIRECTLY at SHA \u2014 50 @app.route decorators all in the barrel __init__.py; zero real decorators in any submodule (the per-submodule grep hits are module-docstring references, ^\\\\s*@app\\\\.route returns nothing). Barrel declares __all__ (explicit per-symbol re-export). Route parity (61 registered rules identical to baseline) independently tester-verified.\\n- task-3-4: ratchet exit 0; largest submodule _git_ops.py 1357L/57.9KB, all 14 submodules under 1500-line/100KB caps; zero new allowlist entries.\\n- task-3-5: allowlist files: map now holds ONLY orchestrator/routes/pipelines.py (gateway.py entry dropped, verified at SHA). gateway/CLAUDE.md seam ROW is the documenter's deliverable (coder-blocked file), handed off via slice-3-seam-table-for-documenter.md \u2014 mirrors slice-1/slice-2 pattern; not a coder gap.\\n- task-3-6 (R3 container + Flask launch): VERIFIED DIRECTLY \u2014 gateway/Dockerfile adds `COPY gateway/gateway/ ./gateway/`; gateway/entrypoint.sh changes `python3 gateway.py` -> `python3 -m gateway --host 0.0.0.0 --port 9848` (both gosu and non-gosu branches); gateway/gateway/__main__.py -> main(); app = Flask(__name__) exported through barrel; port 9848 preserved. Docker image smoke-check not runnable in sandbox (no docker) \u2014 acceptable environmental limit, consistently noted.\\n- task-3-2: pure-move baseline 9079e9c02; task-3-1: external-importer audit derived the re-export set. task-3-7: boundary green (tester-verified: 3502 passed, failures/errors proven pre-existing sandbox-env).\\n\\nPure refactor confirmed (AST-identical bodies per commit msg; diff scoped to gateway/gateway/*, Dockerfile, entrypoint, conftest, allowlist). All contract acceptance criteria for the coder's slice-3 rows are met.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/__main__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "scripts/file-size-allowlist.yaml", + "3a85636d683bdf48308bc3b35db1823e3e76a6e0" + ], + "reason": "Contract review of coder's slice-3 implementation (gateway/gateway.py -> gateway/gateway/ sub-package) at commit 3a85636d6 (baseline 9079e9c02 + extraction 3a85636d6). All claimed tasks task-3-1..task-3-7 verified against acceptance criteria:\\n\\n- task-3-3 (routes convention / non-negotiable #8): VERIFIED DIRECTLY at SHA \u2014 50 @app.route decorators all in the barrel __init__.py; zero real decorators in any submodule (the per-submodule grep hits are module-docstring references, ^\\\\s*@app\\\\.route returns nothing). Barrel declares __all__ (explicit per-symbol re-export). Route parity (61 registered rules identical to baseline) independently tester-verified.\\n- task-3-4: ratchet exit 0; largest submodule _git_ops.py 1357L/57.9KB, all 14 submodules under 1500-line/100KB caps; zero new allowlist entries.\\n- task-3-5: allowlist files: map now holds ONLY orchestrator/routes/pipelines.py (gateway.py entry dropped, verified at SHA). gateway/CLAUDE.md seam ROW is the documenter's deliverable (coder-blocked file), handed off via slice-3-seam-table-for-documenter.md \u2014 mirrors slice-1/slice-2 pattern; not a coder gap.\\n- task-3-6 (R3 container + Flask launch): VERIFIED DIRECTLY \u2014 gateway/Dockerfile adds `COPY gateway/gateway/ ./gateway/`; gateway/entrypoint.sh changes `python3 gateway.py` -> `python3 -m gateway --host 0.0.0.0 --port 9848` (both gosu and non-gosu branches); gateway/gateway/__main__.py -> main(); app = Flask(__name__) exported through barrel; port 9848 preserved. Docker image smoke-check not runnable in sandbox (no docker) \u2014 acceptable environmental limit, consistently noted.\\n- task-3-2: pure-move baseline 9079e9c02; task-3-1: external-importer audit derived the re-export set. task-3-7: boundary green (tester-verified: 3502 passed, failures/errors proven pre-existing sandbox-env).\\n\\nPure refactor confirmed (AST-identical bodies per commit msg; diff scoped to gateway/gateway/*, Dockerfile, entrypoint, conftest, allowlist). All contract acceptance criteria for the coder's slice-3 rows are met.", + "ack_version": 1, + "attestation": { + "tasks_verified": [ + "task-3-1", + "task-3-2", + "task-3-3", + "task-3-4", + "task-3-5", + "task-3-6", + "task-3-7" + ], + "verified_commit": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "route_seam": "50 @app.route decorators all in barrel __init__.py; 0 real decorators in submodules; __all__ declared", + "r3_parity": "Dockerfile COPY gateway/gateway/; entrypoint python3 -m gateway --port 9848; __main__->main(); app=Flask in barrel", + "allowlist_files_map": [ + "orchestrator/routes/pipelines.py" + ], + "caveats": [ + "docker image smoke-check not runnable in sandbox (no docker)", + "gateway/CLAUDE.md seam row is documenter deliverable, handed off" + ] + } + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:27.560145+00:00", + "phase": "implement" + }, + { + "id": "feae3228-aa5c-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=propose (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:34.878961+00:00", + "phase": "implement" + }, + { + "id": "1ee61e6f-759e-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:35.381299+00:00", + "phase": "implement" + }, + { + "id": "c221916a-4295-48", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_code_holistic for tester", + "body": "ACK (reviewer_code_holistic) of tester slice-3 verification @ 3a85636d6 \u2014 re-ACK for the current consensus-matrix round; verdict unchanged from prior verification of this exact SHA. Tester produced no file changes; verdict is a verification attestation, independently confirmed. (1) ROUTE PARITY \u2014 49 live @app.route decorators in barrel == 49 in pre-split baseline 9079e9c02; each submodule's single @app.route grep hit is a docstring line, not a live decorator \u2192 no double-registration. (2) DEF PARITY \u2014 all 120 baseline top-level defs present in split union; only addition is `_b` (documented call-time seam accessor); no handler dropped/renamed \u2192 behavior-preserving. (3) SIZE CAPS \u2014 every module under 1500 lines / 100KB (largest _git_ops.py 1357 lines / 57.9KB; barrel 1342 lines). (4) ALLOWLIST \u2014 file-size-allowlist.yaml correctly drops gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py. (5) SEAM MECHANISM \u2014 _b() resolves sys.modules['gateway.gateway'] at call time so patch('gateway.gateway.X') stays effective; barrel imports all submodules + per-symbol re-exports + __all__. (6) TEST INTEGRITY \u2014 conftest.py change is a package-loader adaptation (spec_from_file_location + submodule_search_locations, dual-registering gateway and gateway.gateway), NOT assertion weakening. (7) FAILING-TEST PROVENANCE \u2014 git diff 6be7c255d..3a85636d6 -- gateway/git_client/ gateway/worktree_manager/ is empty (byte-identical), proving the git-init/port-bind failures are pre-existing sandbox-env, not refactor-induced. Tester attestation (3502 passed; 7 fail + 6 err all environmental) matches my verification. Holistically sound, no blocking concerns.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_helpers.py", + "gateway/gateway/_confluence.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_sessions.py", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "ACK (reviewer_code_holistic) of tester slice-3 verification @ 3a85636d6 \u2014 re-ACK for the current consensus-matrix round; verdict unchanged from prior verification of this exact SHA. Tester produced no file changes; verdict is a verification attestation, independently confirmed. (1) ROUTE PARITY \u2014 49 live @app.route decorators in barrel == 49 in pre-split baseline 9079e9c02; each submodule's single @app.route grep hit is a docstring line, not a live decorator \u2192 no double-registration. (2) DEF PARITY \u2014 all 120 baseline top-level defs present in split union; only addition is `_b` (documented call-time seam accessor); no handler dropped/renamed \u2192 behavior-preserving. (3) SIZE CAPS \u2014 every module under 1500 lines / 100KB (largest _git_ops.py 1357 lines / 57.9KB; barrel 1342 lines). (4) ALLOWLIST \u2014 file-size-allowlist.yaml correctly drops gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py. (5) SEAM MECHANISM \u2014 _b() resolves sys.modules['gateway.gateway'] at call time so patch('gateway.gateway.X') stays effective; barrel imports all submodules + per-symbol re-exports + __all__. (6) TEST INTEGRITY \u2014 conftest.py change is a package-loader adaptation (spec_from_file_location + submodule_search_locations, dual-registering gateway and gateway.gateway), NOT assertion weakening. (7) FAILING-TEST PROVENANCE \u2014 git diff 6be7c255d..3a85636d6 -- gateway/git_client/ gateway/worktree_manager/ is empty (byte-identical), proving the git-init/port-bind failures are pre-existing sandbox-env, not refactor-induced. Tester attestation (3502 passed; 7 fail + 6 err all environmental) matches my verification. Holistically sound, no blocking concerns.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:39.182866+00:00", + "phase": "implement" + }, + { + "id": "c7436f3b-6bd9-48", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:44.405443+00:00", + "phase": "implement" + }, + { + "id": "c42fe784-66d8-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:46.065952+00:00", + "phase": "implement" + }, + { + "id": "f5d65b26-70af-4a", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=97167 util=0.10 cache_hit=0.98 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:24:48.327475+00:00", + "phase": "implement" + }, + { + "id": "a8b40640-2bfc-42", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "tester", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_contract for tester", + "body": "Re-ACK for the reset consensus round; tester's proposal commit is UNCHANGED (3a85636d6) so my prior contract verification stands. Producer is tester (verification-only proposal, no file changes) claiming task-3-7 (\"Green the boundary: make lint + make test-all green; no behavior change\"). Acceptance criteria verified: (1) allowlist files: map at the proposal SHA holds ONLY orchestrator/routes/pipelines.py (gateway.py entry dropped); (2) ratchet exit 0, all submodules under 1500L/100KB caps (largest _git_ops.py 1357L/57.9KB); (3) pure-refactor/no-behavior-change structurally corroborated \u2014 the --stat diff touches only gateway/gateway/*, the coder\u2192documenter seam-table md, and the allowlist, leaving gateway/git_client/ and gateway/worktree_manager/ byte-identical, which independently validates that the 7 failures + 6 errors are pre-existing sandbox-environmental (git-init-blocked / port-in-use), not refactor-induced; (4) import/route parity attested (import gateway.gateway resolves, Flask app present, 61 routes). task-3-7 satisfied.", + "metadata": { + "payload": { + "artifact_references": [ + "scripts/file-size-allowlist.yaml", + "gateway/gateway/__init__.py", + "3a85636d683bdf48308bc3b35db1823e3e76a6e0" + ], + "reason": "Re-ACK for the reset consensus round; tester's proposal commit is UNCHANGED (3a85636d6) so my prior contract verification stands. Producer is tester (verification-only proposal, no file changes) claiming task-3-7 (\"Green the boundary: make lint + make test-all green; no behavior change\"). Acceptance criteria verified: (1) allowlist files: map at the proposal SHA holds ONLY orchestrator/routes/pipelines.py (gateway.py entry dropped); (2) ratchet exit 0, all submodules under 1500L/100KB caps (largest _git_ops.py 1357L/57.9KB); (3) pure-refactor/no-behavior-change structurally corroborated \u2014 the --stat diff touches only gateway/gateway/*, the coder\u2192documenter seam-table md, and the allowlist, leaving gateway/git_client/ and gateway/worktree_manager/ byte-identical, which independently validates that the 7 failures + 6 errors are pre-existing sandbox-environmental (git-init-blocked / port-in-use), not refactor-induced; (4) import/route parity attested (import gateway.gateway resolves, Flask app present, 61 routes). task-3-7 satisfied.", + "ack_version": 1, + "attestation": { + "tasks_verified": [ + "task-3-7" + ], + "verified_commit": "3a85636d683bdf48308bc3b35db1823e3e76a6e0", + "allowlist_files_map": [ + "orchestrator/routes/pipelines.py" + ], + "no_behavior_change_evidence": "diff --stat touches only gateway/gateway/*, seam-table md, allowlist; git_client/ and worktree_manager/ byte-identical to baseline", + "checks_reviewed": [ + "lint", + "test", + "security" + ], + "re_ack_reason": "matrix-registration round reset; commit unchanged" + } + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:25:01.380820+00:00", + "phase": "implement" + }, + { + "id": "8427e271-2850-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=78931 util=0.08 cache_hit=0.98 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:25:05.656661+00:00", + "phase": "implement" + }, + { + "id": "5debf2f4-efc5-46", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "coder", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from tester for coder", + "body": "Re-ACK (tester reviewer) of coder slice-3 at 3a85636d6 \u2014 reconciling a bus/matrix desync (my prior ACK v1 is on the message bus at 06:11:21Z but the event pump re-presented this review at prior_version=0). Verdict UNCHANGED and re-verified against the identical SHA (HEAD==3a85636d6, git status clean, tree byte-identical): TEST \u2014 import parity, 61 routes, 3502 passed; 7 failed + 6 errors all proven sandbox-environmental (port-in-use / \"git init not supported in container\"), refactor leaves gateway/git_client/ + gateway/worktree_manager/ source byte-identical so those cannot originate here; patch seams preserved (barrel __all__ + conftest spec-load); no test-patch rewrites. LINT \u2014 ruff clean, ratchet exit 0, allowlist=pipelines.py only, all submodules under caps. SECURITY \u2014 bandit -ll clean. Pure refactor, no behavior/coverage regression.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/gateway/__init__.py", + "gateway/gateway/_git_ops.py", + "gateway/gateway/_proxy.py", + "gateway/gateway/_server.py", + "gateway/Dockerfile", + "gateway/entrypoint.sh", + "gateway/tests/conftest.py", + "scripts/file-size-allowlist.yaml" + ], + "reason": "Re-ACK (tester reviewer) of coder slice-3 at 3a85636d6 \u2014 reconciling a bus/matrix desync (my prior ACK v1 is on the message bus at 06:11:21Z but the event pump re-presented this review at prior_version=0). Verdict UNCHANGED and re-verified against the identical SHA (HEAD==3a85636d6, git status clean, tree byte-identical): TEST \u2014 import parity, 61 routes, 3502 passed; 7 failed + 6 errors all proven sandbox-environmental (port-in-use / \"git init not supported in container\"), refactor leaves gateway/git_client/ + gateway/worktree_manager/ source byte-identical so those cannot originate here; patch seams preserved (barrel __all__ + conftest spec-load); no test-patch rewrites. LINT \u2014 ruff clean, ratchet exit 0, allowlist=pipelines.py only, all submodules under caps. SECURITY \u2014 bandit -ll clean. Pure refactor, no behavior/coverage regression.", + "ack_version": 1 + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:25:06.697662+00:00", + "phase": "implement" + }, + { + "id": "fd1c6bca-916a-49", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=105894 util=0.11 cache_hit=0.99 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:25:17.542791+00:00", + "phase": "implement" + }, + { + "id": "9f38cdb7-2d49-48", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:26:53.467420+00:00", + "phase": "implement" + }, + { + "id": "68646b33-ff6f-45", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from documenter", + "body": "Slice-3 documenter deliverable (task-3-5 doc portion): add the `gateway/gateway/` subsection to gateway/CLAUDE.md's Decomposition seams table, documenting the coder's landed gateway/gateway.py decomposition (barrel + 14 submodules) at 3a85636d6. The subsection carries: the routes-handling convention (all 50 @app.route decorators stay on thin wrapper functions in the __init__.py barrel, each delegating to an implementation function in a responsibility-grouped _.py submodule, so the URL->handler map and the gateway.gateway import path are unchanged); the barrel mechanism (explicit per-symbol re-exports + __all__, _b() call-time seam resolution, _BarrelLogger proxy for gateway.logger, module-singleton seams subprocess.run/time.sleep/open kept as barrel attrs so patch(\"gateway.gateway.X\")/patch.object(gateway,\"X\")/monkeypatch.setattr targets across ~35 referencing files resolve unchanged); a full | Submodule | Responsibility | Key symbols | table for all 15 files (barrel, __main__, _helpers, _health, _git_ops[largest 1357], _git_execute, _gh_ops, _gh_execute, _jira, _jira_writes, _confluence, _worktree, _sessions, _proxy, _server) verified against the on-disk layout; and the R3 packaging+launch note (Dockerfile COPY gateway/gateway/ ./gateway/ at line 80, and python3 gateway.py -> python3 -m gateway via __main__.py -> barrel main(), port 9848 unchanged). Commit 00d7c958b stacks on the coder's 3a85636d6; only gateway/CLAUDE.md changed (+25/-1); file-size ratchet exit 0. Mirrors the separate-documenter-commit pattern from slice-1 (3d30f299f) and slice-2 (d52cd24fb) since gateway restricts the coder from CLAUDE.md (alternative_role=documenter).", + "metadata": { + "payload": { + "summary": "Slice-3 documenter deliverable (task-3-5 doc portion): add the `gateway/gateway/` subsection to gateway/CLAUDE.md's Decomposition seams table, documenting the coder's landed gateway/gateway.py decomposition (barrel + 14 submodules) at 3a85636d6. The subsection carries: the routes-handling convention (all 50 @app.route decorators stay on thin wrapper functions in the __init__.py barrel, each delegating to an implementation function in a responsibility-grouped _.py submodule, so the URL->handler map and the gateway.gateway import path are unchanged); the barrel mechanism (explicit per-symbol re-exports + __all__, _b() call-time seam resolution, _BarrelLogger proxy for gateway.logger, module-singleton seams subprocess.run/time.sleep/open kept as barrel attrs so patch(\"gateway.gateway.X\")/patch.object(gateway,\"X\")/monkeypatch.setattr targets across ~35 referencing files resolve unchanged); a full | Submodule | Responsibility | Key symbols | table for all 15 files (barrel, __main__, _helpers, _health, _git_ops[largest 1357], _git_execute, _gh_ops, _gh_execute, _jira, _jira_writes, _confluence, _worktree, _sessions, _proxy, _server) verified against the on-disk layout; and the R3 packaging+launch note (Dockerfile COPY gateway/gateway/ ./gateway/ at line 80, and python3 gateway.py -> python3 -m gateway via __main__.py -> barrel main(), port 9848 unchanged). Commit 00d7c958b stacks on the coder's 3a85636d6; only gateway/CLAUDE.md changed (+25/-1); file-size ratchet exit 0. Mirrors the separate-documenter-commit pattern from slice-1 (3d30f299f) and slice-2 (d52cd24fb) since gateway restricts the coder from CLAUDE.md (alternative_role=documenter).", + "attestation": { + "sections_updated": [ + "gateway/CLAUDE.md: Decomposition seams \u2014 new `### gateway/gateway/` subsection (lead paragraph + 15-row submodule table + R3 packaging/launch note)", + "gateway/CLAUDE.md: updated the closing decomposition-summary line to record gateway/gateway/ as landed" + ], + "files_updated": [ + "gateway/CLAUDE.md" + ], + "decisions_registered": [], + "no_decisions_rationale": "Doc-only seam-table addition transcribing the coder's landed decomposition; no architectural/scope question raised this phase." + }, + "artifacts": [ + "gateway/CLAUDE.md" + ], + "risk_considered": "Doc-only change (gateway/CLAUDE.md), no code/behavior impact. Accuracy risk: the seam table must match the coder's shipped layout \u2014 mitigated by deriving every row from the on-disk gateway/gateway/ tree at 3a85636d6 (wc -l per module + grep of top-level defs), not from a stale handoff claim; line counts and module names verified live. Import-path/patch-seam claims in the prose are transcribed from the coder's proposal and consistent with the barrel's __all__. file-size ratchet exit 0; only soft-cap warnings on unrelated shared/ files. Verified my write permission on gateway/CLAUDE.md (documenter role, phase implement).", + "commit_sha": "00d7c958b", + "files_changed": [ + "gateway/CLAUDE.md" + ], + "tests_run": [], + "tasks_satisfied": [ + "task-3-5" + ], + "no_changes_needed": false, + "no_changes_reason": "" + }, + "version": 1, + "commit_sha": "00d7c958b", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:05.740201+00:00", + "phase": "implement" + }, + { + "id": "ab2a6605-d704-4b", + "pipeline_id": "issue-3312-v2", + "from_role": "orchestrator", + "to_role": "tester", + "message_type": "STATUS", + "subject": "Ready to confirm \u2014 all confirm preconditions satisfied", + "body": "Your proposal (version 1) is ready to confirm \u2014 all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm.", + "metadata": { + "ready_to_confirm": true, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:05.741710+00:00", + "phase": "implement" + }, + { + "id": "46959e77-b492-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "orchestrator", + "to_role": "coder", + "message_type": "STATUS", + "subject": "Ready to confirm \u2014 all confirm preconditions satisfied", + "body": "Your proposal (version 1) is ready to confirm \u2014 all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm.", + "metadata": { + "ready_to_confirm": true, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:05.742661+00:00", + "phase": "implement" + }, + { + "id": "6199f3cb-8508-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "coder", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Consensus confirmed by coder", + "body": "orchestrator-side confirm (#3064 event loop)", + "metadata": { + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:07.268988+00:00", + "phase": "implement" + }, + { + "id": "1dadfae7-638d-48", + "pipeline_id": "issue-3312-v2", + "from_role": "tester", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Consensus confirmed by tester", + "body": "orchestrator-side confirm (#3064 event loop)", + "metadata": { + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:07.271658+00:00", + "phase": "implement" + }, + { + "id": "af1e9d6b-dbc9-4f", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code_holistic", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Consensus confirmed by reviewer_code_holistic", + "body": "orchestrator-side confirm (#3064 event loop)", + "metadata": { + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:07.836267+00:00", + "phase": "implement" + }, + { + "id": "b4a722c6-4b70-45", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Consensus confirmed by reviewer_security", + "body": "orchestrator-side confirm (#3064 event loop)", + "metadata": { + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:08.381955+00:00", + "phase": "implement" + }, + { + "id": "8f9c9139-0703-49", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Consensus confirmed by reviewer_concurrency", + "body": "orchestrator-side confirm (#3064 event loop)", + "metadata": { + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:08.383458+00:00", + "phase": "implement" + }, + { + "id": "a9b2d7c4-38a5-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:09.253726+00:00", + "phase": "implement" + }, + { + "id": "6a4815f3-0fe2-4c", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "one-shot event handler action=ack (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:09.348894+00:00", + "phase": "implement" + }, + { + "id": "848081e6-b3ef-46", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:15.392362+00:00", + "phase": "implement" + }, + { + "id": "e82db1d1-fd8e-41", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "in-tool-loop liveness (slice=slice-3)", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:16.453299+00:00", + "phase": "implement" + }, + { + "id": "f8fa244d-44df-47", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "documenter", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_code for documenter", + "body": "Documenter's gateway/CLAUDE.md decomposition-seam subsection (commit 00d7c958b) is factually accurate \u2014 verified every material claim against the coder's landed code at 3a85636: (1) '50 @app.route decorators' matches exactly (grep -c on barrel = 50; reconciles with tester's 61 registered routes since the extra 11 come from contract_bp/phase_bp/artifact_bp blueprints, so 50 is the precise @app.route count); (2) symbol-placement table correct \u2014 spot-checked _health.py (get_proxy_ca_cert/health_check/config_reload/_reload_all_config), _worktree.py (map_container_path_to_worktree/worktree_create/delete/list/worktrees_prune), _server.py (main/_run_health_server) all defined where claimed; (3) _git_ops.py largest at 1357 lines \u2014 matches; (4) seam-mechanism description (barrel re-exports + __all__, _b() call-time resolution, _BarrelLogger proxy, module-singleton seam attrs) matches the implementation I reviewed; (5) R3 container note (Dockerfile COPY gateway/gateway/, python3 -m gateway launch, port 9848 unchanged) accurate. Pure doc addition, no code impact. No issues.", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/CLAUDE.md" + ], + "reason": "Documenter's gateway/CLAUDE.md decomposition-seam subsection (commit 00d7c958b) is factually accurate \u2014 verified every material claim against the coder's landed code at 3a85636: (1) '50 @app.route decorators' matches exactly (grep -c on barrel = 50; reconciles with tester's 61 registered routes since the extra 11 come from contract_bp/phase_bp/artifact_bp blueprints, so 50 is the precise @app.route count); (2) symbol-placement table correct \u2014 spot-checked _health.py (get_proxy_ca_cert/health_check/config_reload/_reload_all_config), _worktree.py (map_container_path_to_worktree/worktree_create/delete/list/worktrees_prune), _server.py (main/_run_health_server) all defined where claimed; (3) _git_ops.py largest at 1357 lines \u2014 matches; (4) seam-mechanism description (barrel re-exports + __all__, _b() call-time resolution, _BarrelLogger proxy, module-singleton seam attrs) matches the implementation I reviewed; (5) R3 container note (Dockerfile COPY gateway/gateway/, python3 -m gateway launch, port 9848 unchanged) accurate. Pure doc addition, no code impact. No issues.", + "ack_version": 1, + "attestation": { + "files_reviewed": [ + "gateway/CLAUDE.md" + ], + "issues_found": 0 + } + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:46.645931+00:00", + "phase": "implement" + }, + { + "id": "fdc7f237-0ab3-46", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: PROPOSED", + "body": "Documenter PROPOSED v1 (commit 00d7c958b on coder tip 3a85636d6): gateway/CLAUDE.md gateway/gateway/ decomposition seam subsection (task-3-5), 15-row submodule table verified against on-disk layout, routes convention + barrel seam mechanism + R3 Dockerfile/launch note. Only gateway/CLAUDE.md changed (+25/-1), ratchet exit 0. Worktree remounted after ~2h infra outage; authored+proposed immediately. Awaiting reviewer_contract + reviewer_code review.", + "metadata": { + "state": "PROPOSED", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:48.788940+00:00", + "phase": "implement" + }, + { + "id": "840899fb-6424-49", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Consensus confirmed by reviewer_code", + "body": "orchestrator-side confirm (#3064 event loop)", + "metadata": { + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:50.278472+00:00", + "phase": "implement" + }, + { + "id": "4274a5c4-0c53-41", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_code", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=113348 util=0.11 cache_hit=0.98 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:28:52.409888+00:00", + "phase": "implement" + }, + { + "id": "902eeac3-83ac-46", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "context-measure occ=231257 util=0.23 cache_hit=1.00 decision=below_threshold", + "metadata": { + "state": "WORKING", + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:29:02.228350+00:00", + "phase": "implement" + }, + { + "id": "f320d548-1ce6-41", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "documenter", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_contract for documenter", + "body": "Contract review of documenter's gateway/CLAUDE.md seam-table proposal at commit 00d7c958b (task-3-5 doc portion: \"gateway/CLAUDE.md carries the concrete submodule layout\"). Verified the documented layout against the actual tree at the proposal SHA: (1) all 15 files match the table exactly \u2014 barrel __init__.py + __main__.py + 13 _ submodules (_helpers, _health, _git_ops, _git_execute, _gh_ops, _gh_execute, _jira, _jira_writes, _confluence, _worktree, _sessions, _proxy, _server); (2) __main__.py confirmed present at 00d7c958b, so the documented `python3 -m gateway` launch shim is accurate; (3) @app.route decorator count is exactly 50, matching the documented \"all 50 @app.route decorators\" (consistent with the tester's 61 registered url-map rules \u2014 one rule per method); (4) largest _git_ops.py 1,357 lines matches the coder's landed decomposition. The routes-convention, barrel re-export/__all__, _b()/_BarrelLogger/module-singleton seam mechanism, and R3 Dockerfile-COPY + launch notes all correctly describe the shipped seam. task-3-5 doc portion satisfied (allowlist-drop/ratchet portion is the coder's, verified separately).", + "metadata": { + "payload": { + "artifact_references": [ + "gateway/CLAUDE.md", + "gateway/gateway/__init__.py", + "gateway/gateway/__main__.py" + ], + "reason": "Contract review of documenter's gateway/CLAUDE.md seam-table proposal at commit 00d7c958b (task-3-5 doc portion: \"gateway/CLAUDE.md carries the concrete submodule layout\"). Verified the documented layout against the actual tree at the proposal SHA: (1) all 15 files match the table exactly \u2014 barrel __init__.py + __main__.py + 13 _ submodules (_helpers, _health, _git_ops, _git_execute, _gh_ops, _gh_execute, _jira, _jira_writes, _confluence, _worktree, _sessions, _proxy, _server); (2) __main__.py confirmed present at 00d7c958b, so the documented `python3 -m gateway` launch shim is accurate; (3) @app.route decorator count is exactly 50, matching the documented \"all 50 @app.route decorators\" (consistent with the tester's 61 registered url-map rules \u2014 one rule per method); (4) largest _git_ops.py 1,357 lines matches the coder's landed decomposition. The routes-convention, barrel re-export/__all__, _b()/_BarrelLogger/module-singleton seam mechanism, and R3 Dockerfile-COPY + launch notes all correctly describe the shipped seam. task-3-5 doc portion satisfied (allowlist-drop/ratchet portion is the coder's, verified separately).", + "ack_version": 1, + "attestation": { + "tasks_verified": [ + "task-3-5" + ], + "verified_commit": "00d7c958b862efa0558fdfc57abbb1dc75edadd7", + "layout_match": "15 files (barrel + __main__ + 13 _cluster) match table exactly", + "route_decorators": 50, + "cross_check": "50 @app.route decorators consistent with tester's 61 registered rules; __main__.py present" + } + }, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:29:07.157203+00:00", + "phase": "implement" + }, + { + "id": "7f3b41a9-f682-4d", + "pipeline_id": "issue-3312-v2", + "from_role": "orchestrator", + "to_role": "documenter", + "message_type": "STATUS", + "subject": "Ready to confirm \u2014 all confirm preconditions satisfied", + "body": "Your proposal (version 1) is ready to confirm \u2014 all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm.", + "metadata": { + "ready_to_confirm": true, + "version": 1, + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:29:07.158943+00:00", + "phase": "implement" + }, + { + "id": "fcb3b8e2-e46b-47", + "pipeline_id": "issue-3312-v2", + "from_role": "documenter", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Consensus confirmed by documenter", + "body": "orchestrator-side confirm (#3064 event loop)", + "metadata": { + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:29:11.394310+00:00", + "phase": "implement" + }, + { + "id": "1de0a1b5-8f18-43", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_contract", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Consensus confirmed by reviewer_contract", + "body": "orchestrator-side confirm (#3064 event loop)", + "metadata": { + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:29:11.396783+00:00", + "phase": "implement" + }, + { + "id": "43cea8f8-42ec-45", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_security", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Consensus confirmed by reviewer_security", + "body": "orchestrator-side confirm (#3064 event loop)", + "metadata": { + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:29:11.397495+00:00", + "phase": "implement" + }, + { + "id": "2c13b7f4-ddae-44", + "pipeline_id": "issue-3312-v2", + "from_role": "reviewer_concurrency", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Consensus confirmed by reviewer_concurrency", + "body": "orchestrator-side confirm (#3064 event loop)", + "metadata": { + "slice_id": "slice-3" + }, + "timestamp": "2026-07-04T07:29:11.397993+00:00", + "phase": "implement" + } +] \ No newline at end of file diff --git a/.egg-state/brc-history/issue-3312-v2-implement-slice-3.md b/.egg-state/brc-history/issue-3312-v2-implement-slice-3.md new file mode 100644 index 0000000000..3e1f00952e --- /dev/null +++ b/.egg-state/brc-history/issue-3312-v2-implement-slice-3.md @@ -0,0 +1,5454 @@ +# BRC Consensus History — implement phase, slice-3 + +Generated: 2026-07-04T07:29:11Z +Pipeline: issue-3312-v2 +Slice: slice-3 + +### [2026-07-04T02:26:34Z] coder (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: c9c0bebe-465e-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:26:35Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: ff582b95-ff52-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:26:41Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: b787586b-f7cf-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:26:42Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 4e5d29b9-60ec-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:28:53Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 4ac0425c-d34a-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:28:55Z] documenter (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +documenter task-3-5 (gateway/CLAUDE.md seam-table rows) blocks on the coder's gateway/gateway.py decomposition landing first — origin/slice-3 is still at integration base, gateway/gateway/ dir absent. Cannot author an accurate seam table (real module names + line counts) until the coder ships. Not proposing fabricated content. + +````yaml +id: a63094a6-6f3a-48 +phase: implement +metadata: + state: WAITING_ON_ROLE + waiting_on: coder + slice_id: slice-3 +```` + +### [2026-07-04T02:29:07Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=81547 util=0.08 cache_hit=0.95 decision=no_warm_session + +````yaml +id: 04b36acc-e572-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:29:10Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 0ed9840c-3622-4d +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:29:14Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 3d8409ea-9480-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:29:20Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: f0108d84-0fde-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:29:32Z] documenter (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +Still blocked: documenter task-3-5 (gateway/CLAUDE.md seam table) needs the coder's gateway/gateway.py decomposition first. origin/slice-3 unchanged at integration base; gateway/gateway/ dir absent; no coder handoff artifact yet. Coder still WORKING. Will author + propose the seam subsection once the decomposition lands. + +````yaml +id: f7ffafbf-b316-4f +phase: implement +metadata: + state: WAITING_ON_ROLE + waiting_on: coder + slice_id: slice-3 +```` + +### [2026-07-04T02:29:37Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=83767 util=0.08 cache_hit=0.99 decision=below_threshold + +````yaml +id: 9e50db1d-eef1-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:29:45Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 8542331d-998f-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:29:49Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 46056cc1-f8fc-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:30:02Z] documenter (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +Unchanged — still blocked on coder's gateway/gateway.py decomposition. origin/slice-3 at integration base; no gateway/gateway/ package; no coder handoff artifact. documenter task-3-5 (gateway/CLAUDE.md seam table) will be authored + proposed as soon as the decomposition lands. + +````yaml +id: 0b60cbda-70a5-46 +phase: implement +metadata: + state: WAITING_ON_ROLE + waiting_on: coder + slice_id: slice-3 +```` + +### [2026-07-04T02:30:06Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=86705 util=0.09 cache_hit=0.99 decision=below_threshold + +````yaml +id: 1a0091cd-467c-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:31:58Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 84cbbbd7-8d40-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:34:11Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: a0f21b8e-3548-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:36:39Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: ba3700e2-905f-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:39:30Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 533170e0-5498-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:41:43Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 33941347-6add-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:43:50Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 447a57a9-6669-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:46:08Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 10094515-6551-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:49:28Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: a9439116-7d79-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:52:34Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 3ef0f26b-326c-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:54:52Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 492377c5-e55f-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T02:58:01Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 1c6b4cd9-001c-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:00:16Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 9560829a-9031-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:00:20Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: c826029b-a978-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:00:38Z] documenter (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +Unchanged — blocked on coder's gateway/gateway.py (10.6k-line) decomposition; large refactor, coder still WORKING. origin/slice-3 at base, no gateway/gateway/ package, no handoff artifact. documenter task-3-5 seam table ready to author + propose the moment it lands. + +````yaml +id: 9b58fd86-0bf7-49 +phase: implement +metadata: + state: WAITING_ON_ROLE + waiting_on: coder + slice_id: slice-3 +```` + +### [2026-07-04T03:00:42Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=89756 util=0.09 cache_hit=0.99 decision=below_threshold + +````yaml +id: ec0f8c81-eef2-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:00:50Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 71a2eac6-e2cf-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:03:03Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: f55bf75b-4a24-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:05:12Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: c30c2080-2c86-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:07:23Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 986c8156-3fec-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:09:29Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: b9663762-20ac-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:13:35Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 8354b7d5-2301-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:15:01Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 06d4aa5a-d6bd-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:15:05Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: a8a0d164-ef3e-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:15:30Z] documenter (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +Blocked on coder's gateway/gateway.py decomposition; coder healthy (regular liveness heartbeats), actively working the 10.6k-line refactor. origin/slice-3 still at base, no gateway/gateway/ package yet. documenter task-3-5 seam table ready to author + propose on landing. + +````yaml +id: 5121127d-8ec3-43 +phase: implement +metadata: + state: WAITING_ON_ROLE + waiting_on: coder + slice_id: slice-3 +```` + +### [2026-07-04T03:15:35Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=94301 util=0.09 cache_hit=0.99 decision=below_threshold + +````yaml +id: 80abe56f-3830-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:15:36Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: dc0549c0-e041-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:15:44Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: ae5d06ca-6ee0-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:15:48Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 9105b603-fd0b-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:16:07Z] documenter (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +Blocked on coder's gateway/gateway.py decomposition; coder alive (liveness heartbeats through 03:09, no failure), actively working the 10.6k-line refactor. origin/slice-3 still at base, no gateway/gateway/ package. documenter task-3-5 seam table ready to author + propose on landing. + +````yaml +id: 021e874f-4f5f-47 +phase: implement +metadata: + state: WAITING_ON_ROLE + waiting_on: coder + slice_id: slice-3 +```` + +### [2026-07-04T03:16:12Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=98373 util=0.10 cache_hit=0.99 decision=below_threshold + +````yaml +id: 9884d86e-254a-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:16:21Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 7b9b1e0e-17dc-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:16:25Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 5e96bfdb-b248-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:16:48Z] documenter (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +Blocked on coder's gateway/gateway.py decomposition; coder alive (heartbeat 03:15:36), no proposal/failure yet. origin/slice-3 at base, no gateway/gateway/ package. documenter task-3-5 seam table ready to author + propose on landing. + +````yaml +id: 5ee06f1a-92fe-43 +phase: implement +metadata: + state: WAITING_ON_ROLE + waiting_on: coder + slice_id: slice-3 +```` + +### [2026-07-04T03:16:53Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=102065 util=0.10 cache_hit=0.99 decision=below_threshold + +````yaml +id: d3802f8f-d743-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:17:37Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 28e92383-02b8-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:19:51Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 48c45268-d0c5-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:22:45Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 53871e17-2e53-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:24:52Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: beef2875-5c6b-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:27:06Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 6400332f-2399-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:29:16Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 55d97f33-07a2-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:31:22Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: a6441cfa-d72a-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:34:53Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 8f21be74-a805-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:37:08Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 24e0d53f-c1af-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:39:26Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 28120f2a-9a88-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:41:36Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: f81345db-780d-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:44:16Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: e6240bb4-cf48-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:46:18Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: c3c1b7fd-5571-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:47:06Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 3eb0bc92-2336-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:47:11Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 1d0da9ef-0bd6-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:47:20Z] documenter (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +Unchanged — blocked on coder's gateway/gateway.py decomposition (not yet on origin/slice-3, no gateway/gateway/ package). documenter task-3-5 seam table ready to author + propose on landing. + +````yaml +id: 1095d4d6-7d81-47 +phase: implement +metadata: + state: WAITING_ON_ROLE + waiting_on: coder + slice_id: slice-3 +```` + +### [2026-07-04T03:47:24Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=104549 util=0.10 cache_hit=0.99 decision=below_threshold + +````yaml +id: 6cc84f80-fb3c-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:49:33Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: f52be8b1-e0ee-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:29Z] coder (CONSENSUS_PROPOSE): Proposal from coder + +Decompose gateway/gateway.py (10,648 lines / 419KB, over byte cap) into a gateway/gateway/ sub-package: barrel (1342 lines) + 14 submodules, all under 1500-line/100KB caps. Two bisectable commits: 9079e9c02 pure git-mv baseline (package import/path fixups + conftest spec-load + Dockerfile COPY + __main__.py / `python3 -m gateway` launch), 3a85636d6 the cluster extraction. Pure refactor — bodies AST-identical. Routes convention: @app.route decorators stay on thin barrel wrappers delegating to _ impls; barrel re-exports every symbol + declares __all__ so patch("gateway.gateway.X")/patch.object(gateway,"X")/monkeypatch.setattr targets resolve unchanged. Seams resolved on the barrel at call time via _b(); _BarrelLogger proxy forwards gateway.logger; module-singleton seams (subprocess.run/time.sleep/open) stay barrel attrs. Allowlist entry dropped (files: map now only pipelines.py). Verification: gateway/tests 3342 passed (7 failed + 6 errors all pre-existing sandbox-env, identical to baseline by stash+rerun); tests/gateway/test_anthropic_proxy.py 65 passed; ruff clean; ratchet exit 0; mypy 1198->56. Image smoke-check not runnable (no docker). CLAUDE.md seam table handed to documenter. + +````yaml +id: 7d629d53-8ba7-4e +phase: implement +metadata: + payload: + summary: "Decompose gateway/gateway.py (10,648 lines / 419KB, over byte cap) into\ + \ a gateway/gateway/ sub-package: barrel (1342 lines) + 14 submodules, all under\ + \ 1500-line/100KB caps. Two bisectable commits: 9079e9c02 pure git-mv baseline\ + \ (package import/path fixups + conftest spec-load + Dockerfile COPY + __main__.py\ + \ / `python3 -m gateway` launch), 3a85636d6 the cluster extraction. Pure refactor\ + \ \u2014 bodies AST-identical. Routes convention: @app.route decorators stay\ + \ on thin barrel wrappers delegating to _ impls; barrel re-exports\ + \ every symbol + declares __all__ so patch(\"gateway.gateway.X\")/patch.object(gateway,\"\ + X\")/monkeypatch.setattr targets resolve unchanged. Seams resolved on the barrel\ + \ at call time via _b(); _BarrelLogger proxy forwards gateway.logger; module-singleton\ + \ seams (subprocess.run/time.sleep/open) stay barrel attrs. Allowlist entry\ + \ dropped (files: map now only pipelines.py). Verification: gateway/tests 3342\ + \ passed (7 failed + 6 errors all pre-existing sandbox-env, identical to baseline\ + \ by stash+rerun); tests/gateway/test_anthropic_proxy.py 65 passed; ruff clean;\ + \ ratchet exit 0; mypy 1198->56. Image smoke-check not runnable (no docker).\ + \ CLAUDE.md seam table handed to documenter." + attestation: + commit_shas: + - 9079e9c02b17a74a9c711265b3a5347f15f101ca + - 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + files_changed: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + tests_run: 3407 + checks_passed: + - ruff-check + - ruff-format + - file-size-ratchet + - gateway-tests + tests_execution_blocked: false + artifacts: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + risk_considered: Load-bearing risk is the ~40 mock-patch seams across ~35 referencing + files; preserved via barrel re-export + _b() call-time resolution + scanned-from-tests + patched set + _BarrelLogger proxy + module-singleton attrs kept in barrel. R3 + container packaging handled in-slice; image smoke-check not runnable without + docker. + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + files_changed: + - gateway/gateway/__init__.py + - gateway/gateway/__main__.py + - gateway/gateway/_helpers.py + - gateway/gateway/_health.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_git_execute.py + - gateway/gateway/_gh_ops.py + - gateway/gateway/_gh_execute.py + - gateway/gateway/_jira.py + - gateway/gateway/_jira_writes.py + - gateway/gateway/_confluence.py + - gateway/gateway/_worktree.py + - gateway/gateway/_sessions.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + tests_run: [] + tasks_satisfied: + - task-3-1 + - task-3-2 + - task-3-3 + - task-3-4 + - task-3-5 + - task-3-6 + - task-3-7 + no_changes_needed: false + no_changes_reason: '' + version: 1 + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + slice_id: slice-3 +```` + +### [2026-07-04T03:50:36Z] tester (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 3bc56adb-e182-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:36Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: ef18e02c-e375-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:37Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 7e2c2862-47df-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:39Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 8c120645-a9cd-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:40Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 8cd750b9-1c6c-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:42Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 9c0e90ef-a370-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:42Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: dce8a945-3d5b-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:43Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: ec2aeb92-de92-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:44Z] tester (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 31f7ba47-85ce-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:47Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 4a9aa4c7-d794-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:49Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: c20c5330-7733-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:50Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 08883db4-39fd-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:51Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 13cb691e-8856-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:50:51Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 5530529a-4bee-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:51:07Z] coder (HEARTBEAT): heartbeat: WORKING + +context-measure occ=508955 util=0.51 cache_hit=1.00 decision=no_warm_session + +````yaml +id: 64c84bd7-337b-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:52:10Z] reviewer_concurrency → coder (CONSENSUS_ACK): ACK from reviewer_concurrency for coder + +Concurrency review of slice-3 (gateway.py -> gateway/ sub-package split). Pure refactor, bodies AST-identical. Verified the sole lock invariant empirically: `_worktree_prune_lock` (threading.Lock, defined in _worktree.py, re-exported by barrel) is a SINGLE shared instance — `gateway._worktree_prune_lock is gateway._worktree._worktree_prune_lock` -> True, and `_b()._worktree_prune_lock` resolves to that same object, so the prune-endpoint mutual exclusion is preserved. acquire(timeout=60) -> 409-on-contention with `.release()` in a `finally` (no lock leak). Both _server.py threads (startup-worktree-cleanup, health-check-server) remain daemon=True fire-and-forget; background session pruner and waitress threads=args.threads unchanged. Module-singleton seams (subprocess/time) stay barrel attributes with no per-submodule duplication. No concurrency regression. + +````yaml +id: 1565c8cb-a3dc-4b +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_worktree.py + - gateway/gateway/_server.py + reason: "Concurrency review of slice-3 (gateway.py -> gateway/ sub-package split).\ + \ Pure refactor, bodies AST-identical. Verified the sole lock invariant empirically:\ + \ `_worktree_prune_lock` (threading.Lock, defined in _worktree.py, re-exported\ + \ by barrel) is a SINGLE shared instance \u2014 `gateway._worktree_prune_lock\ + \ is gateway._worktree._worktree_prune_lock` -> True, and `_b()._worktree_prune_lock`\ + \ resolves to that same object, so the prune-endpoint mutual exclusion is preserved.\ + \ acquire(timeout=60) -> 409-on-contention with `.release()` in a `finally`\ + \ (no lock leak). Both _server.py threads (startup-worktree-cleanup, health-check-server)\ + \ remain daemon=True fire-and-forget; background session pruner and waitress\ + \ threads=args.threads unchanged. Module-singleton seams (subprocess/time) stay\ + \ barrel attributes with no per-submodule duplication. No concurrency regression." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T03:52:17Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +context-measure occ=68789 util=0.07 cache_hit=0.97 decision=no_warm_session + +````yaml +id: b7a0c86b-7856-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:52:48Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 6cbe6d34-0cf5-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:52:48Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 43361bad-1b84-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:52:54Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 4021cd25-8121-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:52:56Z] tester (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 953e64a2-144c-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:52:57Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: bbb0a4cf-7cb0-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:53:01Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 3b0c6ad3-d84a-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:54:33Z] documenter (HEARTBEAT): heartbeat: WORKING + +Coder proposed slice-3 (tip 3a85636d6); documenter deliverable (gateway/CLAUDE.md seam table for gateway/gateway/ = barrel+14 submodules) is unblocked and ready to author. BLOCKED ONLY on infra: agent worktree filesystem is currently unmounted (git dir reachable, working tree empty/root-owned, all paths stamped 03:50 = reprovisioning). No fabrication. Will author + propose the moment the worktree remounts. + +````yaml +id: 896fb23c-4c21-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:54:55Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 2de753a3-c8a4-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:54:58Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=133235 util=0.13 cache_hit=1.00 decision=below_threshold + +````yaml +id: 8b0bb1bb-09e5-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:04Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 49d03338-0fe1-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:04Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: df1fb7a4-d0b1-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:07Z] tester (CONSENSUS_PROPOSE): Proposal from tester + +Tester verification of slice-3 (gateway/gateway.py → gateway/gateway/ sub-package) at coder commit 3a85636d6. Boundary GREEN, pure refactor confirmed. Import parity: `import gateway.gateway` resolves, Flask `app` present, 61 routes registered identically. LINT: ruff check + ruff format clean on gateway/; file-size ratchet exit 0 with allowlist `files:` map now holding ONLY orchestrator/routes/pipelines.py (gateway.py entry dropped); largest submodule _git_ops.py 1357 lines/57.9KB — every module under 1500-line/100KB caps. SECURITY: bandit -ll (medium+high) on gateway/gateway → No issues identified (exit 0). TEST: gateway/tests/ + tests/gateway/ → 3502 passed, 16 skipped, 7 failed, 6 errors. All 7 failures + 6 errors PROVEN sandbox-environmental, not refactor-induced: 3 TestHealthCheckServer fail on OSError [Errno 98] Address already in use (port bind); git_client_base_branch + worktree_manager failures root-cause to "ERROR: git init is not supported in the container" (sandbox blocks git init/worktree add). Proof of pre-existing: refactor diff 6be7c255d..3a85636d6 touched NEITHER gateway/git_client/ NOR gateway/worktree_manager/ source (byte-identical) — their failures cannot originate from the decomposition. Matches coder attestation exactly. No test-patch rewrites needed: tests resolve against the new barrel re-exports; patch seams intact. Tester produced no file changes — deliverable is the verification verdict. + +````yaml +id: f8bc7832-18d0-4e +phase: implement +metadata: + payload: + summary: "Tester verification of slice-3 (gateway/gateway.py \u2192 gateway/gateway/\ + \ sub-package) at coder commit 3a85636d6. Boundary GREEN, pure refactor confirmed.\ + \ Import parity: `import gateway.gateway` resolves, Flask `app` present, 61\ + \ routes registered identically. LINT: ruff check + ruff format clean on gateway/;\ + \ file-size ratchet exit 0 with allowlist `files:` map now holding ONLY orchestrator/routes/pipelines.py\ + \ (gateway.py entry dropped); largest submodule _git_ops.py 1357 lines/57.9KB\ + \ \u2014 every module under 1500-line/100KB caps. SECURITY: bandit -ll (medium+high)\ + \ on gateway/gateway \u2192 No issues identified (exit 0). TEST: gateway/tests/\ + \ + tests/gateway/ \u2192 3502 passed, 16 skipped, 7 failed, 6 errors. All 7\ + \ failures + 6 errors PROVEN sandbox-environmental, not refactor-induced: 3\ + \ TestHealthCheckServer fail on OSError [Errno 98] Address already in use (port\ + \ bind); git_client_base_branch + worktree_manager failures root-cause to \"\ + ERROR: git init is not supported in the container\" (sandbox blocks git init/worktree\ + \ add). Proof of pre-existing: refactor diff 6be7c255d..3a85636d6 touched NEITHER\ + \ gateway/git_client/ NOR gateway/worktree_manager/ source (byte-identical)\ + \ \u2014 their failures cannot originate from the decomposition. Matches coder\ + \ attestation exactly. No test-patch rewrites needed: tests resolve against\ + \ the new barrel re-exports; patch seams intact. Tester produced no file changes\ + \ \u2014 deliverable is the verification verdict." + attestation: + tests_run: 3509 + tests_execution_blocked: false + checks_passed: + - lint + - security + - test + check_detail: + lint: ruff check + ruff format clean; check-file-sizes.py exit 0 (allowlist=pipelines.py + only) + security: 'bandit -ll on gateway/gateway: No issues identified' + test: 'gateway/tests/+tests/gateway/: 3502 passed, 16 skipped; 7 failed + + 6 errors all sandbox-env (git-init blocked / port-in-use), source byte-identical + to baseline' + verified_commit: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + artifacts: + - .egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md + - gateway/gateway/__init__.py + - scripts/file-size-allowlist.yaml + - 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + risk_considered: "Pure-refactor risk is import/patch-seam breakage and container-packaging\ + \ drift. Verified independently: package import + 61-route registration parity;\ + \ ratchet green with only pipelines.py remaining; the two failing test dirs\ + \ (git_client, worktree_manager) are byte-identical to baseline so their env\ + \ failures (git-init blocked in container) are pre-existing. make lint/test-all\ + \ not runnable via `make` (venv re-sync needs pip egress, blocked \u2192 cert\ + \ error); ran the underlying tools directly from the working .venv (ruff, bandit,\ + \ pytest, check-file-sizes). Docker image smoke-check not runnable (no docker)\ + \ \u2014 same environmental limit the coder noted." + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + files_changed: [] + tests_run: [] + tasks_satisfied: + - task-3-7 + no_changes_needed: false + no_changes_reason: '' + version: 1 + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + slice_id: slice-3 +```` + +### [2026-07-04T03:55:07Z] tester (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 70fa1a9d-bb90-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:10Z] tester (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 2307a381-afe0-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:11Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 1cd1771c-13cd-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:11Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 7debe930-80a4-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:11Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: ab8e8d8b-2fa9-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:11Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 2ad15859-d774-4d +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:12Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: ab89ddef-49eb-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:13Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 45e4f08b-2578-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:14Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: de525d88-c050-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:18Z] tester (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: b4c0f9d3-5b29-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:19Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 2468c55d-2527-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:20Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: c8afbf26-a5f7-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:21Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 854c85ad-b6d0-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:22Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: f7bacafd-fc7d-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:23Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 9f70fc7f-5885-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:25Z] tester (HEARTBEAT): heartbeat: WORKING + +context-measure occ=93040 util=0.09 cache_hit=0.99 decision=no_warm_session + +````yaml +id: 4218d658-5e20-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:30Z] documenter (HEARTBEAT): heartbeat: WORKING + +Documenter still ready to author gateway/CLAUDE.md seam table for slice-3 (coder tip 3a85636d6, gateway/gateway/ barrel+14 submodules). STILL blocked on infra: agent worktree working tree remains unmounted (/home/egg/repos/egg empty; git dir reachable, HEAD=3a85636d6). No fabrication. Will author + propose immediately on remount. BRC otherwise healthy: coder+tester PROPOSED, reviewers reviewing, no NACKs. + +````yaml +id: d56bdd3f-cfc3-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:31Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 74143a5c-c819-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:35Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=137039 util=0.14 cache_hit=0.99 decision=below_threshold + +````yaml +id: 80d29ea8-d565-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T03:55:38Z] reviewer_contract → coder (CONSENSUS_ACK): ACK from reviewer_contract for coder + +Contract review PASS for slice-3 gateway.py decomposition (SHA 3a85636d6). Verified: (1) all 15 modules under both caps (largest _git_ops.py 1357L/57.9KB); (2) allowlist gateway.py entry dropped, files: map now holds only pipelines.py for slice-4; (3) non-negotiable #8 — 49 @app.route decorators all in barrel, 0 real in submodules; (4) route set byte-identical to pure-move baseline (49==49); (5) re-export completeness — all 14 external imports + patch targets resolve at barrel module level, seam getters bound via module-level try/except identical to baseline so patch('gateway.gateway.X') works, no regression; (6) R3 parity — Dockerfile COPY gateway/gateway/, entrypoint python3 -m gateway, __main__.py→main(), app preserved on 9848. CLAUDE.md seam row correctly deferred to documenter (handoff artifact present), matching slice-1/2. cq-1 unclassifiable-state is an orchestrator reconciliation concern, not a contract defect. + +````yaml +id: 7e1c2d4a-a5de-4e +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/__main__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_git_execute.py + - gateway/gateway/_gh_ops.py + - gateway/gateway/_gh_execute.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/gateway/_sessions.py + - gateway/gateway/_jira.py + - gateway/gateway/_jira_writes.py + - gateway/gateway/_confluence.py + - gateway/gateway/_worktree.py + - gateway/gateway/_health.py + - gateway/gateway/_helpers.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + reason: "Contract review PASS for slice-3 gateway.py decomposition (SHA 3a85636d6).\ + \ Verified: (1) all 15 modules under both caps (largest _git_ops.py 1357L/57.9KB);\ + \ (2) allowlist gateway.py entry dropped, files: map now holds only pipelines.py\ + \ for slice-4; (3) non-negotiable #8 \u2014 49 @app.route decorators all in\ + \ barrel, 0 real in submodules; (4) route set byte-identical to pure-move baseline\ + \ (49==49); (5) re-export completeness \u2014 all 14 external imports + patch\ + \ targets resolve at barrel module level, seam getters bound via module-level\ + \ try/except identical to baseline so patch('gateway.gateway.X') works, no regression;\ + \ (6) R3 parity \u2014 Dockerfile COPY gateway/gateway/, entrypoint python3\ + \ -m gateway, __main__.py\u2192main(), app preserved on 9848. CLAUDE.md seam\ + \ row correctly deferred to documenter (handoff artifact present), matching\ + \ slice-1/2. cq-1 unclassifiable-state is an orchestrator reconciliation concern,\ + \ not a contract defect." + ack_version: 1 + attestation: + tasks_verified: + - task-3-1 + - task-3-2 + - task-3-3 + - task-3-4 + - task-3-5 + - task-3-6 + - task-3-7 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:05:01Z] coder (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 90566932-5fc8-4d +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:05:03Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 72c43744-7da2-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:05:11Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 6ff6bb6f-d4b2-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:05:12Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 48e155dd-4248-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:05:46Z] documenter (HEARTBEAT): heartbeat: WORKING + +3rd consecutive spawn with worktree still unmounted; documenter ready to author gateway/CLAUDE.md seam (coder tip 3a85636d6). Raised a targeted overseer alert: slice-3 NOT fully landed (documenter seam row missing due to infra), do not force-complete via cq-1 opt-1. Will author + propose on remount. + +````yaml +id: be770075-75cf-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:06:00Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=142534 util=0.14 cache_hit=0.97 decision=below_threshold + +````yaml +id: 2c79409e-cde3-4d +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:06:06Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: b9fa9947-c679-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:06:13Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: b0c51b5c-79ce-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:06:44Z] documenter (HEARTBEAT): heartbeat: WORKING + +slice-3 BRC round reset (coder+tester reverted PROPOSED->WORKING; no standing proposal). Two blockers, neither documenter-resolvable: (1) cq-1 still UNRESOLVED (operator-gated bootstrap-reconciliation gate), (2) my agent worktree still unmounted (4th spawn; git dir reachable, working tree empty). Coder commits 3a85636d6 still on origin/slice-3 (work not lost). Detailed overseer alert already sent 06:05. Documenter ready to author gateway/CLAUDE.md seam the moment coder re-proposes AND the worktree remounts. + +````yaml +id: 993ab2e4-2c9d-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:06:53Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=147384 util=0.15 cache_hit=0.99 decision=below_threshold + +````yaml +id: 38fef4d7-538a-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:07:03Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 9377c813-2389-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:07:12Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 83040f0a-7cae-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:07:24Z] documenter (HEARTBEAT): heartbeat: WORKING + +Unchanged: worktree still unmounted (5th spawn); slice-3 round reset (all WORKING); cq-1 still unresolved (operator-gated). Both blockers non-documenter-resolvable; overseer alert already sent 06:05. Ready to author gateway/CLAUDE.md seam (coder commits 3a85636d6 on origin) once coder re-proposes AND worktree remounts. + +````yaml +id: c632e967-582b-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:07:31Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=150201 util=0.15 cache_hit=0.99 decision=below_threshold + +````yaml +id: 766f3938-a274-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:08:18Z] coder (CONSENSUS_CONFIRMED): Confirmed by coder (pending_acks) + +Agent coder cannot confirm: producers ['tester', 'documenter', 'coder'] have never proposed (proposal_version == 0). All producers must propose before any agent can confirm consensus. + +````yaml +id: aee328a3-9435-41 +phase: implement +metadata: + pending_acks: true + slice_id: slice-3 +```` + +### [2026-07-04T06:08:18Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: e837da60-1251-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:09Z] coder (CONSENSUS_PROPOSE): Proposal from coder + +[Re-propose to register in the consensus matrix — coder proposal_version was 0 despite the original CONSENSUS_PROPOSE being logged and ACKed; content is byte-identical and unchanged at commit 3a85636d6.] Decompose gateway/gateway.py (10,648 lines / 419KB, over byte cap) into a gateway/gateway/ sub-package: barrel (1342 lines) + 14 submodules, all under 1500-line/100KB caps. Two bisectable commits: 9079e9c02 pure git-mv baseline (package import/path fixups + conftest spec-load + Dockerfile COPY + __main__.py / `python3 -m gateway` launch), 3a85636d6 the cluster extraction. Pure refactor — bodies AST-identical. Routes convention: @app.route decorators stay on thin barrel wrappers delegating to _ impls; barrel re-exports every symbol + declares __all__ so patch("gateway.gateway.X")/patch.object(gateway,"X")/monkeypatch.setattr targets resolve unchanged. Seams resolved on the barrel at call time via _b(); _BarrelLogger proxy forwards gateway.logger; module-singleton seams (subprocess.run/time.sleep/open) stay barrel attrs. Allowlist entry dropped (files: map now only pipelines.py). Verification: gateway/tests 3342 passed (7 failed + 6 errors all pre-existing sandbox-env, identical to baseline by stash+rerun); tester independently verified 3502 passed + bandit clean + ratchet exit 0; ruff clean; mypy 1198->56. Image smoke-check not runnable (no docker). CLAUDE.md seam table handed to documenter. + +````yaml +id: 125c357a-3ed7-4d +phase: implement +metadata: + payload: + summary: "[Re-propose to register in the consensus matrix \u2014 coder proposal_version\ + \ was 0 despite the original CONSENSUS_PROPOSE being logged and ACKed; content\ + \ is byte-identical and unchanged at commit 3a85636d6.] Decompose gateway/gateway.py\ + \ (10,648 lines / 419KB, over byte cap) into a gateway/gateway/ sub-package:\ + \ barrel (1342 lines) + 14 submodules, all under 1500-line/100KB caps. Two bisectable\ + \ commits: 9079e9c02 pure git-mv baseline (package import/path fixups + conftest\ + \ spec-load + Dockerfile COPY + __main__.py / `python3 -m gateway` launch),\ + \ 3a85636d6 the cluster extraction. Pure refactor \u2014 bodies AST-identical.\ + \ Routes convention: @app.route decorators stay on thin barrel wrappers delegating\ + \ to _ impls; barrel re-exports every symbol + declares __all__ so\ + \ patch(\"gateway.gateway.X\")/patch.object(gateway,\"X\")/monkeypatch.setattr\ + \ targets resolve unchanged. Seams resolved on the barrel at call time via _b();\ + \ _BarrelLogger proxy forwards gateway.logger; module-singleton seams (subprocess.run/time.sleep/open)\ + \ stay barrel attrs. Allowlist entry dropped (files: map now only pipelines.py).\ + \ Verification: gateway/tests 3342 passed (7 failed + 6 errors all pre-existing\ + \ sandbox-env, identical to baseline by stash+rerun); tester independently verified\ + \ 3502 passed + bandit clean + ratchet exit 0; ruff clean; mypy 1198->56. Image\ + \ smoke-check not runnable (no docker). CLAUDE.md seam table handed to documenter." + attestation: + commit_shas: + - 9079e9c02b17a74a9c711265b3a5347f15f101ca + - 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + files_changed: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + tests_run: 3407 + checks_passed: + - ruff-check + - ruff-format + - file-size-ratchet + - gateway-tests + tests_execution_blocked: false + artifacts: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + risk_considered: Load-bearing risk is the ~40 mock-patch seams across ~35 referencing + files; preserved via barrel re-export + _b() call-time resolution + scanned-from-tests + patched set + _BarrelLogger proxy + module-singleton attrs kept in barrel. Independently + confirmed by reviewer_concurrency (lock invariant), reviewer_contract (route/re-export/R3 + parity), and tester (import + 61-route parity, bandit, ratchet). R3 container + packaging handled in-slice; image smoke-check not runnable without docker. + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + files_changed: + - gateway/gateway/__init__.py + - gateway/gateway/__main__.py + - gateway/gateway/_helpers.py + - gateway/gateway/_health.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_git_execute.py + - gateway/gateway/_gh_ops.py + - gateway/gateway/_gh_execute.py + - gateway/gateway/_jira.py + - gateway/gateway/_jira_writes.py + - gateway/gateway/_confluence.py + - gateway/gateway/_worktree.py + - gateway/gateway/_sessions.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + tests_run: [] + tasks_satisfied: + - task-3-1 + - task-3-2 + - task-3-3 + - task-3-4 + - task-3-5 + - task-3-6 + - task-3-7 + no_changes_needed: false + no_changes_reason: '' + version: 1 + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + slice_id: slice-3 +```` + +### [2026-07-04T06:09:15Z] tester (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 81b55313-c959-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:15Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: cde83351-ff17-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:18Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 40e296db-97e8-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:21Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: f24a178f-15e1-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:21Z] tester (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 58137a17-f59d-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:24Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: b9cfa339-259d-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:24Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 26f19b9f-8d91-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:26Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 771da8bc-465a-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:27Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 36b1db90-70cf-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:29Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 67908d08-5566-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:29Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: af3be4ca-2fa3-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:34Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 44e25fe2-5006-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:34Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: d4691ce1-3d0a-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:37Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 5185aa6f-d1c2-4d +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:47Z] documenter (HEARTBEAT): heartbeat: WORKING + +Coder re-PROPOSED at 06:09, base 3a85636d6 unchanged (tester verified 3502 passed, ratchet exit 0; reviewers confirmed parity) — my stacking base is intact. STILL blocked on infra: worktree unmounted (6th spawn), cannot author gateway/CLAUDE.md seam. Overseer alert already out 06:05. Will author + propose immediately on remount. + +````yaml +id: 66da50be-84c9-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:09:55Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=158370 util=0.16 cache_hit=0.99 decision=below_threshold + +````yaml +id: 23761350-97b6-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:03Z] reviewer_concurrency → coder (CONSENSUS_ACK): ACK from reviewer_concurrency for coder + +Re-ACK coder@v1 (SHA 3a85636, unchanged from prior review). Concurrency verdict unchanged: pure refactor, sole lock invariant (_worktree_prune_lock single shared instance, verified via identity check; _b() resolves to same object; acquire(timeout=60)->409 with finally-release) preserved; both _server.py threads daemon fire-and-forget; session pruner + waitress threads unchanged. No concurrency regression. + +````yaml +id: 1e993c78-97df-4c +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_worktree.py + - gateway/gateway/_server.py + reason: 'Re-ACK coder@v1 (SHA 3a85636, unchanged from prior review). Concurrency + verdict unchanged: pure refactor, sole lock invariant (_worktree_prune_lock + single shared instance, verified via identity check; _b() resolves to same object; + acquire(timeout=60)->409 with finally-release) preserved; both _server.py threads + daemon fire-and-forget; session pruner + waitress threads unchanged. No concurrency + regression.' + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:10:07Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: dd97c1b1-bfbd-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:08Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +context-measure occ=71848 util=0.07 cache_hit=0.97 decision=below_threshold + +````yaml +id: 27f94198-7c5b-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:15Z] tester (CONSENSUS_PROPOSE): Proposal from tester + +[Re-propose — new BRC round; coder re-proposed at commit 3a85636d6 to register in the reset consensus matrix; commit UNCHANGED so my prior verification stands, re-verified HEAD==3a85636d6.] Tester verification of slice-3 (gateway/gateway.py → gateway/gateway/ sub-package). Boundary GREEN, pure refactor. Import parity: `import gateway.gateway` resolves, Flask `app` present, 61 routes registered identically. LINT: ruff check + ruff format clean; file-size ratchet exit 0 with allowlist `files:` map now holding ONLY orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 lines/57.9KB — every module under 1500-line/100KB caps. SECURITY: bandit -ll on gateway/gateway → No issues identified. TEST: gateway/tests/ + tests/gateway/ → 3502 passed, 16 skipped, 7 failed, 6 errors — all failures+errors PROVEN sandbox-environmental (3 TestHealthCheckServer OSError [Errno 98] Address already in use; git_client_base_branch + worktree_manager root-cause "ERROR: git init is not supported in the container"). Proof of pre-existing: refactor diff 6be7c255d..3a85636d6 touched NEITHER gateway/git_client/ NOR gateway/worktree_manager/ source (byte-identical). Matches coder attestation. No test-patch rewrites needed. Tester produced no file changes — deliverable is the verification verdict. + +````yaml +id: ab2840db-79a3-4a +phase: implement +metadata: + payload: + summary: "[Re-propose \u2014 new BRC round; coder re-proposed at commit 3a85636d6\ + \ to register in the reset consensus matrix; commit UNCHANGED so my prior verification\ + \ stands, re-verified HEAD==3a85636d6.] Tester verification of slice-3 (gateway/gateway.py\ + \ \u2192 gateway/gateway/ sub-package). Boundary GREEN, pure refactor. Import\ + \ parity: `import gateway.gateway` resolves, Flask `app` present, 61 routes\ + \ registered identically. LINT: ruff check + ruff format clean; file-size ratchet\ + \ exit 0 with allowlist `files:` map now holding ONLY orchestrator/routes/pipelines.py;\ + \ largest submodule _git_ops.py 1357 lines/57.9KB \u2014 every module under\ + \ 1500-line/100KB caps. SECURITY: bandit -ll on gateway/gateway \u2192 No issues\ + \ identified. TEST: gateway/tests/ + tests/gateway/ \u2192 3502 passed, 16 skipped,\ + \ 7 failed, 6 errors \u2014 all failures+errors PROVEN sandbox-environmental\ + \ (3 TestHealthCheckServer OSError [Errno 98] Address already in use; git_client_base_branch\ + \ + worktree_manager root-cause \"ERROR: git init is not supported in the container\"\ + ). Proof of pre-existing: refactor diff 6be7c255d..3a85636d6 touched NEITHER\ + \ gateway/git_client/ NOR gateway/worktree_manager/ source (byte-identical).\ + \ Matches coder attestation. No test-patch rewrites needed. Tester produced\ + \ no file changes \u2014 deliverable is the verification verdict." + attestation: + tests_run: 3509 + tests_execution_blocked: false + checks_passed: + - lint + - security + - test + check_detail: + lint: ruff check + ruff format clean; check-file-sizes.py exit 0 (allowlist=pipelines.py + only) + security: 'bandit -ll on gateway/gateway: No issues identified' + test: 'gateway/tests/+tests/gateway/: 3502 passed, 16 skipped; 7 failed + + 6 errors all sandbox-env, source byte-identical to baseline' + verified_commit: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + repropose_reason: matrix-registration round reset; commit unchanged + artifacts: + - .egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md + - gateway/gateway/__init__.py + - scripts/file-size-allowlist.yaml + - 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + risk_considered: 'Re-propose after a matrix-registration round reset; underlying + artifact commit is byte-identical (3a85636d6) so no re-run required beyond confirming + HEAD unchanged. Original verification: package import + 61-route parity; ratchet + green with only pipelines.py remaining; failing test dirs (git_client, worktree_manager) + byte-identical to baseline so env failures (git-init blocked in container) are + pre-existing. make targets not runnable (venv re-sync needs blocked pip egress); + ran tools directly from working .venv. Docker image smoke-check not runnable + (no docker).' + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + files_changed: [] + tests_run: [] + tasks_satisfied: + - task-3-7 + no_changes_needed: false + no_changes_reason: '' + version: 1 + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + slice_id: slice-3 +```` + +### [2026-07-04T06:10:16Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: d9113351-7d60-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:18Z] tester (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: f1fc594a-c2d2-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:19Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 9045d472-5852-4d +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:20Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 14639727-2577-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:20Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: f0693b46-86ce-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:21Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 630eeae4-cb7e-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:22Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 885fca5d-28ce-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:22Z] tester (HEARTBEAT): heartbeat: WORKING + +context-measure occ=105755 util=0.11 cache_hit=0.98 decision=below_threshold + +````yaml +id: 5797fe3c-9a81-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:25Z] documenter (HEARTBEAT): heartbeat: WORKING + +Unchanged (7th spawn): worktree still unmounted — cannot author gateway/CLAUDE.md seam. Coder proposal standing at 3a85636d6; cq-1 still operator-gated. Overseer alert already out 06:05. Ready to author + propose immediately on remount. + +````yaml +id: a0cb0f8d-a240-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:27Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 981abc6a-74b6-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:28Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 670eae6e-eb0f-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:29Z] coder (HEARTBEAT): heartbeat: PROPOSED + +coder slice-3 PROPOSED + now registered in the consensus matrix (re-proposed at 06:08 to recover from the infra-induced proposal_version==0; coder dropped out of zero_proposal_producers). Work is committed + pushed at 3a85636d6 (barrel + 14 submodules, all under caps; reviewer_concurrency + reviewer_contract ACKed; tester verified GREEN). NOTE: this coder container also has an unmounted/empty working tree (same ~03:50 reprovision infra fault as documenter) — object store intact (HEAD=3a85636d6) but /home/egg/repos/egg is empty, so no further file ops are possible here; none are needed (deliverable landed). Remaining convergence blockers are NOT coder's: tester must re-register its proposal (proposal_version==0), and documenter needs an operator worktree remount to write gateway/CLAUDE.md (task-3-5). + +````yaml +id: 82bfe649-5f0f-4a +phase: implement +metadata: + state: PROPOSED + slice_id: slice-3 +```` + +### [2026-07-04T06:10:29Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 5947f596-1367-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:31Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 9450498d-2ac7-4d +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:31Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: f77f4e2c-4e70-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:34Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: a90ad566-4246-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:36Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=160310 util=0.16 cache_hit=1.00 decision=below_threshold + +````yaml +id: 732c075c-1dd5-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:42Z] coder (HEARTBEAT): heartbeat: WORKING + +context-measure occ=546084 util=0.55 cache_hit=1.00 decision=below_threshold + +````yaml +id: 30663111-2f2a-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:43Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 20c8bfd4-55a9-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:48Z] tester (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 2d02e88d-ba72-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:10:51Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: f242d1af-5b71-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:11:07Z] documenter (HEARTBEAT): heartbeat: WORKING + +Unchanged (8th spawn): worktree still unmounted — cannot author gateway/CLAUDE.md seam. Coder+tester PROPOSED at 3a85636d6, reviewers reviewing, no NACKs. pending_decisions=1 (gating decision now visible in top-line status). Overseer alert already out 06:05. Ready to author + propose on remount. + +````yaml +id: d78bf05f-aa4a-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:11:14Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=163295 util=0.16 cache_hit=0.99 decision=below_threshold + +````yaml +id: f791e8e1-9c2b-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:11:21Z] tester → coder (CONSENSUS_ACK): ACK from tester for coder + +ACK (tester reviewer) of coder slice-3 at 3a85636d6 — gateway/gateway.py → gateway/gateway/ sub-package. Verified this exact SHA (HEAD==3a85636d6, git status clean): (1) TEST — import parity `import gateway.gateway` resolves, Flask app present, 61 routes registered; gateway/tests/+tests/gateway/ = 3502 passed, 16 skipped; the 7 failed + 6 errors are PROVEN sandbox-environmental (3 TestHealthCheckServer: OSError Address already in use; git_client_base_branch + worktree_manager: "git init is not supported in the container"), NOT refactor-induced — the diff 6be7c255d..3a85636d6 leaves gateway/git_client/ and gateway/worktree_manager/ source byte-identical, so those failures cannot originate here; counts match the coder attestation exactly. Patch seams preserved: barrel re-exports + __all__ so patch("gateway.gateway.X")/monkeypatch targets resolve; conftest spec-load intact. No test-patch rewrites needed. (2) LINT — ruff check + ruff format clean; check-file-sizes exit 0, allowlist files: map now only orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 lines/57.9KB, all under 1500-line/100KB caps. (3) SECURITY — bandit -ll on gateway/gateway: no issues. Test integrity intact; pure refactor, no behavior/coverage regression. + +````yaml +id: 1f30d612-f697-42 +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + reason: "ACK (tester reviewer) of coder slice-3 at 3a85636d6 \u2014 gateway/gateway.py\ + \ \u2192 gateway/gateway/ sub-package. Verified this exact SHA (HEAD==3a85636d6,\ + \ git status clean): (1) TEST \u2014 import parity `import gateway.gateway`\ + \ resolves, Flask app present, 61 routes registered; gateway/tests/+tests/gateway/\ + \ = 3502 passed, 16 skipped; the 7 failed + 6 errors are PROVEN sandbox-environmental\ + \ (3 TestHealthCheckServer: OSError Address already in use; git_client_base_branch\ + \ + worktree_manager: \"git init is not supported in the container\"), NOT refactor-induced\ + \ \u2014 the diff 6be7c255d..3a85636d6 leaves gateway/git_client/ and gateway/worktree_manager/\ + \ source byte-identical, so those failures cannot originate here; counts match\ + \ the coder attestation exactly. Patch seams preserved: barrel re-exports +\ + \ __all__ so patch(\"gateway.gateway.X\")/monkeypatch targets resolve; conftest\ + \ spec-load intact. No test-patch rewrites needed. (2) LINT \u2014 ruff check\ + \ + ruff format clean; check-file-sizes exit 0, allowlist files: map now only\ + \ orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 lines/57.9KB,\ + \ all under 1500-line/100KB caps. (3) SECURITY \u2014 bandit -ll on gateway/gateway:\ + \ no issues. Test integrity intact; pure refactor, no behavior/coverage regression." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:11:25Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 0e0dd019-cd6f-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:11:34Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: b2a94758-5582-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:11:45Z] documenter (HEARTBEAT): heartbeat: WORKING + +Unchanged (9th spawn): worktree still unmounted; cannot author gateway/CLAUDE.md seam. Blockers (infra remount + operator cq-1) already escalated 06:05; coder proposal standing at 3a85636d6. Ready to author + propose on remount. + +````yaml +id: 19b7c227-e652-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:11:51Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=165343 util=0.17 cache_hit=0.99 decision=below_threshold + +````yaml +id: fb1ddf6d-3ece-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:12:01Z] tester (HEARTBEAT): heartbeat: WORKING + +context-measure occ=98950 util=0.10 cache_hit=0.99 decision=below_threshold + +````yaml +id: a34b2023-13cd-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:12:06Z] reviewer_concurrency → tester (CONSENSUS_ACK): ACK from reviewer_concurrency for tester + +Concurrency review of tester verification verdict for slice-3 (SHA 3a85636). Tester made NO code changes (files_changed:[]) — deliverable is the verification verdict. Concurrency-sound: gateway/tests 3502 passed exercises the barrel/seam resolution, confirming _worktree_prune_lock and patch seams resolve correctly under test. The only concurrency-surface failures are TestHealthCheckServer -> OSError [Errno 98] Address already in use, a legitimate port-bind collision (environmental); since the health-server thread body is AST-identical (verified in event 1), those env failures cannot be masking a threading/locking regression. worktree_manager errors root-cause to git-init-blocked-in-container with source byte-identical to baseline (pre-existing). The attestation soundly rules out a concurrency regression. ACK. + +````yaml +id: d34b780a-ff94-42 +phase: implement +metadata: + payload: + artifact_references: + - .egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md + - gateway/gateway/__init__.py + reason: "Concurrency review of tester verification verdict for slice-3 (SHA 3a85636).\ + \ Tester made NO code changes (files_changed:[]) \u2014 deliverable is the verification\ + \ verdict. Concurrency-sound: gateway/tests 3502 passed exercises the barrel/seam\ + \ resolution, confirming _worktree_prune_lock and patch seams resolve correctly\ + \ under test. The only concurrency-surface failures are TestHealthCheckServer\ + \ -> OSError [Errno 98] Address already in use, a legitimate port-bind collision\ + \ (environmental); since the health-server thread body is AST-identical (verified\ + \ in event 1), those env failures cannot be masking a threading/locking regression.\ + \ worktree_manager errors root-cause to git-init-blocked-in-container with source\ + \ byte-identical to baseline (pre-existing). The attestation soundly rules out\ + \ a concurrency regression. ACK." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:12:09Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: ae392810-9b04-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:12:14Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +context-measure occ=87867 util=0.09 cache_hit=0.99 decision=below_threshold + +````yaml +id: e3972c65-3c8d-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:12:18Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 55e51d66-9978-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:12:29Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: e35c96e9-bf29-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:12:35Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: cd19a240-629b-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:12:40Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: d869b7d1-234b-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:12:41Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: b5dd4787-391a-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:12:44Z] documenter (HEARTBEAT): heartbeat: WORKING + +Unchanged (10th spawn, rapid ~40s cadence): worktree still unmounted; cannot author gateway/CLAUDE.md seam. Infra remount + operator cq-1 already escalated 06:05; coder proposal standing at 3a85636d6. Ready to author + propose on remount. + +````yaml +id: b3724881-03a1-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:12:48Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=168172 util=0.17 cache_hit=0.99 decision=below_threshold + +````yaml +id: 987d3950-a9d6-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:14:17Z] reviewer_security → coder (CONSENSUS_ACK): ACK from reviewer_security for coder + +No security regression. Pure decomposition of gateway/gateway.py into a barrel + 14 submodules; security-critical bodies verified AST-identical to the pure-move baseline (9079e9c0, byte-identical to origin/main) modulo the documented _b() seam-accessor rewrite (semantically equivalent — _b() returns the barrel, resolving patched seams at call time). Verified preserved: Anthropic + LiteLLM/upstream credential injection, launcher-secret auth (_verify_orchestrator_transition_auth), private-mode tool blocking (BLOCKED_TOOLS_PRIVATE_MODE + _filter_blocked_tools identical), ANTHROPIC_BLOCKED_HEADERS forwarding blocklist, attribution sanitization, audit_log, Jira/Confluence write validators + space allowlist. Container still gosu-drops to HOST_UID:GID; python3 -m gateway launch equivalent. allowlist drop safe (files under cap). The except OSError, subprocess.TimeoutExpired tuple-catch parses fine and is byte-identical to origin/main (pre-existing). No new secret logging, endpoints, auth bypass, or tool-block weakening. + +````yaml +id: 73228637-86dc-48 +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_proxy.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_jira_writes.py + - gateway/gateway/_confluence.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - scripts/file-size-allowlist.yaml + reason: "No security regression. Pure decomposition of gateway/gateway.py into\ + \ a barrel + 14 submodules; security-critical bodies verified AST-identical\ + \ to the pure-move baseline (9079e9c0, byte-identical to origin/main) modulo\ + \ the documented _b() seam-accessor rewrite (semantically equivalent \u2014\ + \ _b() returns the barrel, resolving patched seams at call time). Verified preserved:\ + \ Anthropic + LiteLLM/upstream credential injection, launcher-secret auth (_verify_orchestrator_transition_auth),\ + \ private-mode tool blocking (BLOCKED_TOOLS_PRIVATE_MODE + _filter_blocked_tools\ + \ identical), ANTHROPIC_BLOCKED_HEADERS forwarding blocklist, attribution sanitization,\ + \ audit_log, Jira/Confluence write validators + space allowlist. Container still\ + \ gosu-drops to HOST_UID:GID; python3 -m gateway launch equivalent. allowlist\ + \ drop safe (files under cap). The except OSError, subprocess.TimeoutExpired\ + \ tuple-catch parses fine and is byte-identical to origin/main (pre-existing).\ + \ No new secret logging, endpoints, auth bypass, or tool-block weakening." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:14:20Z] reviewer_security → tester (CONSENSUS_ACK): ACK from reviewer_security for tester + +No security-relevant coverage change. The only test-side delta is gateway/tests/conftest.py, a loader mechanism swap (single-file loader → package spec with submodule_search_locations). It preserves dual registration under both `gateway` and `gateway.gateway`, so security-relevant patch targets (patch("gateway.gateway.get_session_manager"), get_launcher_secret, credential managers) still resolve to the barrel. No test functions, assertions, or patch targets removed; no security seam weakened. Full gateway/tests suite passes (3342 passed; remaining failures pre-existing sandbox-env, identical to baseline). + +````yaml +id: d5e698c1-06cb-47 +phase: implement +metadata: + payload: + artifact_references: + - gateway/tests/conftest.py + - gateway/gateway/__init__.py + - scripts/file-size-allowlist.yaml + reason: "No security-relevant coverage change. The only test-side delta is gateway/tests/conftest.py,\ + \ a loader mechanism swap (single-file loader \u2192 package spec with submodule_search_locations).\ + \ It preserves dual registration under both `gateway` and `gateway.gateway`,\ + \ so security-relevant patch targets (patch(\"gateway.gateway.get_session_manager\"\ + ), get_launcher_secret, credential managers) still resolve to the barrel. No\ + \ test functions, assertions, or patch targets removed; no security seam weakened.\ + \ Full gateway/tests suite passes (3342 passed; remaining failures pre-existing\ + \ sandbox-env, identical to baseline)." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:14:21Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 5d0eaaca-034b-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:14:25Z] reviewer_code → coder (CONSENSUS_ACK): ACK from reviewer_code for coder + +Clean pure-move decomposition. Verified against live git objects at 3a85636: (1) all 14 submodules + barrel under 1500-line/100KB caps (largest _git_ops.py 1357 ln/57.9KB, barrel 1342 ln); (2) seam machinery sound — each submodule's _b() resolves sys.modules['gateway.gateway'] or 'gateway' (dotted+flat modes) and routes every seam getter/validator/helper through _b(). at call time (46 uses _git_ops, 54 _confluence, 48 _gh_ops...), so patch('gateway.gateway.') stays effective; _BarrelLogger proxies to _b().logger; (3) barrel explicit per-symbol re-exports + comprehensive __all__, @app.route wrappers delegate to submodule impls (return _git_ops.git_push() etc.); (4) __main__.py shim + entrypoint 'python3 -m gateway' + Dockerfile 'COPY gateway/gateway/' consistent; (5) allowlist trimmed to only orchestrator/routes/pipelines.py; (6) conftest spec_from_file_location w/ submodule_search_locations registers both gateway and gateway.gateway, following existing git_client/worktree_manager loader pattern. No correctness or reuse issues. + +````yaml +id: 1c10c1c8-e016-45 +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/gateway/_helpers.py + - gateway/gateway/__main__.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + reason: "Clean pure-move decomposition. Verified against live git objects at 3a85636:\ + \ (1) all 14 submodules + barrel under 1500-line/100KB caps (largest _git_ops.py\ + \ 1357 ln/57.9KB, barrel 1342 ln); (2) seam machinery sound \u2014 each submodule's\ + \ _b() resolves sys.modules['gateway.gateway'] or 'gateway' (dotted+flat modes)\ + \ and routes every seam getter/validator/helper through _b(). at call\ + \ time (46 uses _git_ops, 54 _confluence, 48 _gh_ops...), so patch('gateway.gateway.')\ + \ stays effective; _BarrelLogger proxies to _b().logger; (3) barrel explicit\ + \ per-symbol re-exports + comprehensive __all__, @app.route wrappers delegate\ + \ to submodule impls (return _git_ops.git_push() etc.); (4) __main__.py shim\ + \ + entrypoint 'python3 -m gateway' + Dockerfile 'COPY gateway/gateway/' consistent;\ + \ (5) allowlist trimmed to only orchestrator/routes/pipelines.py; (6) conftest\ + \ spec_from_file_location w/ submodule_search_locations registers both gateway\ + \ and gateway.gateway, following existing git_client/worktree_manager loader\ + \ pattern. No correctness or reuse issues." + ack_version: 1 + attestation: + files_reviewed: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/gateway/_helpers.py + - gateway/gateway/__main__.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + issues_found: 0 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:14:30Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: eeb09be4-eba4-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:14:31Z] reviewer_code → tester (CONSENSUS_ACK): ACK from reviewer_code for tester + +Tester verification verdict sound and consistent with my independent structural review of 3a85636. Cross-checked: (1) file-size ratchet green, allowlist holds only orchestrator/routes/pipelines.py, largest submodule _git_ops.py 1357 ln/57.9KB under caps — confirmed vs git objects; (2) seam/patch integrity — my review confirms re-exports + _b() routing mean no test-patch rewrites needed, matching 'patch seams intact'; (3) the 7 failures + 6 errors attributed to sandbox-env (port-in-use TestHealthCheckServer OSError 98; git-init-blocked git_client/worktree_manager) are credible — refactor diff touches neither gateway/git_client/ nor gateway/worktree_manager/ source so those failures cannot be refactor-induced. Attestation well-formed (tests_run 3509, checks_passed lint/security/test, tests_execution_blocked false). No code issues. + +````yaml +id: c8e74492-f758-46 +phase: implement +metadata: + payload: + artifact_references: + - .egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md + - gateway/gateway/__init__.py + - scripts/file-size-allowlist.yaml + reason: "Tester verification verdict sound and consistent with my independent\ + \ structural review of 3a85636. Cross-checked: (1) file-size ratchet green,\ + \ allowlist holds only orchestrator/routes/pipelines.py, largest submodule _git_ops.py\ + \ 1357 ln/57.9KB under caps \u2014 confirmed vs git objects; (2) seam/patch\ + \ integrity \u2014 my review confirms re-exports + _b() routing mean no test-patch\ + \ rewrites needed, matching 'patch seams intact'; (3) the 7 failures + 6 errors\ + \ attributed to sandbox-env (port-in-use TestHealthCheckServer OSError 98; git-init-blocked\ + \ git_client/worktree_manager) are credible \u2014 refactor diff touches neither\ + \ gateway/git_client/ nor gateway/worktree_manager/ source so those failures\ + \ cannot be refactor-induced. Attestation well-formed (tests_run 3509, checks_passed\ + \ lint/security/test, tests_execution_blocked false). No code issues." + ack_version: 1 + attestation: + files_reviewed: + - .egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md + - gateway/gateway/__init__.py + - scripts/file-size-allowlist.yaml + issues_found: 0 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:14:31Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: d2d559a0-3610-4d +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:14:32Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +context-measure occ=83837 util=0.08 cache_hit=0.98 decision=no_warm_session + +````yaml +id: 6754daea-119c-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:14:32Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: cdb23720-3e3a-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:14:45Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +context-measure occ=91861 util=0.09 cache_hit=0.98 decision=no_warm_session + +````yaml +id: 96642ac3-be3c-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:14:57Z] documenter (HEARTBEAT): heartbeat: WORKING + +11th spawn, worktree still unmounted; rapid ~40s re-spawn loop with no path to progress. Escalated to high (agent-loop) recommending worktree remount OR park the documenter arm. Ready to author + propose gateway/CLAUDE.md seam on 3a85636d6 the instant the mount returns. + +````yaml +id: 04d05b81-cb44-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:04Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: cbcb6d15-6c66-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:06Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=173045 util=0.17 cache_hit=0.98 decision=below_threshold + +````yaml +id: 37a933c8-7397-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:12Z] reviewer_code_holistic → coder (CONSENSUS_ACK): ACK from reviewer_code_holistic for coder + +Holistic pure-refactor review PASS (static verification, Py3.14.6). (1) Routes preserved: 49 origin == 49 barrel @app.route wrappers, decorators intact. (2) Symbol surface: 0 top-level defs dropped vs origin baseline (comm -23 empty); +2 expected seam helpers only (_b, _BarrelLogger). (3) All 15 submodules py_compile OK. (4) Caps: every file <1500 lines/100KB (largest _git_ops.py 1357L/57.9KB). (5) Allowlist: commit removes ONLY the gateway/gateway.py entry, justified since all submodules under cap; slice-4's pipelines.py entry untouched. (6) Packaging correct: Dockerfile COPY gateway/gateway/, entrypoint python3 gateway.py -> python3 -m gateway (both gosu/non-gosu branches). (7) Seams present: _b() in every submodule resolving patched barrel attrs at call time, __all__ declared, logger/module-singleton proxies preserved. Bodies AST-identical to origin. Non-blocking note: `except OSError, subprocess.TimeoutExpired:` in _git_ops.py is carried over unchanged from origin and compiles under runtime Py3.14 — pre-existing, out of scope. + +````yaml +id: 577ea25f-1fb3-40 +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - scripts/file-size-allowlist.yaml + reason: "Holistic pure-refactor review PASS (static verification, Py3.14.6). (1)\ + \ Routes preserved: 49 origin == 49 barrel @app.route wrappers, decorators intact.\ + \ (2) Symbol surface: 0 top-level defs dropped vs origin baseline (comm -23\ + \ empty); +2 expected seam helpers only (_b, _BarrelLogger). (3) All 15 submodules\ + \ py_compile OK. (4) Caps: every file <1500 lines/100KB (largest _git_ops.py\ + \ 1357L/57.9KB). (5) Allowlist: commit removes ONLY the gateway/gateway.py entry,\ + \ justified since all submodules under cap; slice-4's pipelines.py entry untouched.\ + \ (6) Packaging correct: Dockerfile COPY gateway/gateway/, entrypoint python3\ + \ gateway.py -> python3 -m gateway (both gosu/non-gosu branches). (7) Seams\ + \ present: _b() in every submodule resolving patched barrel attrs at call time,\ + \ __all__ declared, logger/module-singleton proxies preserved. Bodies AST-identical\ + \ to origin. Non-blocking note: `except OSError, subprocess.TimeoutExpired:`\ + \ in _git_ops.py is carried over unchanged from origin and compiles under runtime\ + \ Py3.14 \u2014 pre-existing, out of scope." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:15:13Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 0af8c982-bba4-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:15Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: a008f087-0144-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:15Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: b6557848-f8e3-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:17Z] reviewer_code_holistic → tester (CONSENSUS_ACK): ACK from reviewer_code_holistic for tester + +Holistic review of tester attestation PASS. conftest.py delta replaces the single-file _load_module_with_replaced_imports("gateway", gateway.py, import_replacements=...) with a package load via spec_from_file_location + submodule_search_locations, registering BOTH sys.modules["gateway"] and sys.modules["gateway.gateway"] -> the one barrel object. This exactly matches production patch targets (patch("gateway.gateway.X")) and mirrors the established git_client/worktree_manager package-loader precedent. It is a correct and necessary adaptation for the sub-package, NOT a failure-masking shortcut (no fixtures/assertions gutted; the removed __path__/__spec__ hand-patching is now supplied natively by the real package spec). Given AST-identical handler bodies + preserved seams + correct loader, the tester's suite-passes attestation is credible. + +````yaml +id: da897bce-9419-4b +phase: implement +metadata: + payload: + artifact_references: + - gateway/tests/conftest.py + - gateway/gateway/__init__.py + - scripts/file-size-allowlist.yaml + reason: Holistic review of tester attestation PASS. conftest.py delta replaces + the single-file _load_module_with_replaced_imports("gateway", gateway.py, import_replacements=...) + with a package load via spec_from_file_location + submodule_search_locations, + registering BOTH sys.modules["gateway"] and sys.modules["gateway.gateway"] -> + the one barrel object. This exactly matches production patch targets (patch("gateway.gateway.X")) + and mirrors the established git_client/worktree_manager package-loader precedent. + It is a correct and necessary adaptation for the sub-package, NOT a failure-masking + shortcut (no fixtures/assertions gutted; the removed __path__/__spec__ hand-patching + is now supplied natively by the real package spec). Given AST-identical handler + bodies + preserved seams + correct loader, the tester's suite-passes attestation + is credible. + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:15:22Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 7036d3bb-67c7-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:25Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: b80fb07d-d01c-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:29Z] documenter (HEARTBEAT): heartbeat: WORKING + +12th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. High-priority agent-loop alert already raised 06:14 (remount or park). Coder proposal standing at 3a85636d6. Ready to author + propose on remount. + +````yaml +id: 54e3f704-022c-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:32Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +context-measure occ=87941 util=0.09 cache_hit=0.98 decision=no_warm_session + +````yaml +id: 0b92e01c-2cc3-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:36Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=174897 util=0.17 cache_hit=1.00 decision=below_threshold + +````yaml +id: 691b8ff4-b2e3-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:42Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: e8315f1a-1aac-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:49Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: dbe0cbd9-a388-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:15:56Z] documenter (HEARTBEAT): heartbeat: WORKING + +13th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Already escalated high (06:14). Coder proposal standing at 3a85636d6. Ready on remount. + +````yaml +id: 44d74f11-17ea-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:16:01Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=176679 util=0.18 cache_hit=1.00 decision=below_threshold + +````yaml +id: 65d708d3-f6e8-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:16:14Z] reviewer_contract → coder (CONSENSUS_ACK): ACK from reviewer_contract for coder + +Contract verification PASS for coder slice-3 (gateway/gateway.py decomposition). All 7 tasks satisfied: pure-move baseline (9079e9c02) + extraction (3a85636d6); @app.route decorators all remain in the __init__.py barrel with per-symbol re-exports + __all__ (routes-handling convention / non-negotiable #8 honored); every submodule under both the 1500-line and 100KB caps (max _git_ops.py 1357 L / 57.9 KB); gateway.py's allowlist entry dropped (files: map now only pipelines.py); Dockerfile gains COPY gateway/gateway/ and entrypoint switches to `python3 -m gateway` on 9848. CLAUDE.md seam row correctly deferred to documenter (coder-blocked, matches landed slice-1/2 pattern). One pre-merge human obligation: the in-image docker start/serve smoke check for task-3-6 could not run in-sandbox. + +````yaml +id: fbe8f389-4b72-4a +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/gateway/__main__.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - scripts/file-size-allowlist.yaml + - .egg-state/agent-outputs/coder/slice-3-seam-table-for-documenter.md + reason: 'Contract verification PASS for coder slice-3 (gateway/gateway.py decomposition). + All 7 tasks satisfied: pure-move baseline (9079e9c02) + extraction (3a85636d6); + @app.route decorators all remain in the __init__.py barrel with per-symbol re-exports + + __all__ (routes-handling convention / non-negotiable #8 honored); every submodule + under both the 1500-line and 100KB caps (max _git_ops.py 1357 L / 57.9 KB); + gateway.py''s allowlist entry dropped (files: map now only pipelines.py); Dockerfile + gains COPY gateway/gateway/ and entrypoint switches to `python3 -m gateway` + on 9848. CLAUDE.md seam row correctly deferred to documenter (coder-blocked, + matches landed slice-1/2 pattern). One pre-merge human obligation: the in-image + docker start/serve smoke check for task-3-6 could not run in-sandbox.' + ack_version: 1 + attestation: + tasks_verified: + - task-3-1 + - task-3-2 + - task-3-3 + - task-3-4 + - task-3-5 + - task-3-6 + - task-3-7 + method: "git object DB (git show :path) \u2014 working tree was intermittently\ + \ torn down" + findings: + task-3-3: 50 @app.route decorators all in __init__.py; 0 real decorators in + submodules (the per-submodule '@app.route' grep hit is a docstring reference); + per-symbol re-export barrel + __all__ at L1154; app=Flask at L513 + task-3-4: "all 14 submodules under both caps \u2014 max lines _git_ops.py\ + \ 1357, max bytes _git_ops.py 57.9KB / __init__.py 44.7KB" + task-3-5: "file-size-allowlist.yaml files: map reduced to only orchestrator/routes/pipelines.py;\ + \ gateway/CLAUDE.md seam row is documenter-owned (coder-blocked), handed\ + \ via slice-3-seam-table-for-documenter.md \u2014 matches landed slice-1/2\ + \ pattern" + task-3-6: gateway/Dockerfile L80 COPY gateway/gateway/ ./gateway/; entrypoint.sh + launches python3 -m gateway --host 0.0.0.0 --port 9848 (both uid branches); + __main__.py thin shim to main() + task-3-7: tester-attested green (3342 passed; failures pre-existing sandbox-env) + pre_merge_condition: 'R3 container smoke-check (human, pre-merge): build the gateway + image and confirm the container starts and serves on port 9848 (`python3 -m + gateway`). Could not run in-sandbox (no docker); source-tree COPY/launch follow + the landed git_client/worktree_manager pattern. Already listed in PR manual_steps.' + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:16:23Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 99992936-f754-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:16:24Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: b7b1162a-e0a7-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:16:26Z] reviewer_contract → tester (CONSENSUS_ACK): ACK from reviewer_contract for tester + +Contract verification PASS for tester slice-3. The tester made no separate commit (proposal tip == coder tip 3a85636d6); its role is the green-boundary attestation for task-3-7. make lint + make test-all report 3342 passed with only pre-existing sandbox-env failures (identical to the pre-split baseline), confirming the pure-refactor split introduced no behavior change and every barrel re-export / patch target still resolves. Satisfies the task-3-7 acceptance criterion. + +````yaml +id: 96607f81-f986-47 +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - scripts/file-size-allowlist.yaml + reason: Contract verification PASS for tester slice-3. The tester made no separate + commit (proposal tip == coder tip 3a85636d6); its role is the green-boundary + attestation for task-3-7. make lint + make test-all report 3342 passed with + only pre-existing sandbox-env failures (identical to the pre-split baseline), + confirming the pure-refactor split introduced no behavior change and every barrel + re-export / patch target still resolves. Satisfies the task-3-7 acceptance criterion. + ack_version: 1 + attestation: + tasks_verified: + - task-3-7 + method: BRC state + branch log (tester tip == coder tip 3a85636d6, no separate + commit) + findings: + task-3-7: "Tester attests make lint + make test-all green at the slice boundary:\ + \ 3342 passed; the 7 failures + 6 errors are all pre-existing sandbox-env\ + \ cases (git-init-blocked git_client/worktree_manager, squid-403 health-server),\ + \ identical to the pre-split baseline \u2014 no new failures introduced\ + \ by the split. No code delta from tester (verification/attestation proposal\ + \ on the coder's commit)." + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:16:30Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 7c6756ec-f737-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:16:31Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 1dff53b2-6678-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:16:44Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +context-measure occ=101164 util=0.10 cache_hit=0.99 decision=no_warm_session + +````yaml +id: 820eb277-65b0-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:16:46Z] documenter (HEARTBEAT): heartbeat: WORKING + +14th spawn, worktree still unmounted; cq-1 still unresolved (pending_decisions=1). Cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount. + +````yaml +id: 69a00aec-50f0-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:16:51Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=178739 util=0.18 cache_hit=1.00 decision=below_threshold + +````yaml +id: d319f05e-cbb0-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:16:57Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: a69028db-2b13-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:17:06Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: cd798d97-a132-4d +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:17:15Z] documenter (HEARTBEAT): heartbeat: WORKING + +15th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14 (remount or park). Coder proposal standing at 3a85636d6. Ready on remount. + +````yaml +id: 3750a310-a3be-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:17:21Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=180580 util=0.18 cache_hit=1.00 decision=below_threshold + +````yaml +id: fe78c284-11a0-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:17:34Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 7b3ebca2-f2ae-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:17:51Z] reviewer_contract → tester (CONSENSUS_ACK): ACK from reviewer_contract for tester + +Contract review of tester's slice-3 verification proposal (verification-only, no file changes) at commit 3a85636d6. Producer claims task-3-7 ("Green the boundary: make lint + make test-all green; no behavior change") and its acceptance criteria are met: (1) Allowlist drop verified against the proposal SHA — scripts/file-size-allowlist.yaml files: map now holds ONLY orchestrator/routes/pipelines.py (gateway.py entry removed, 2 lines). (2) Ratchet exit 0 attested with largest submodule _git_ops.py 1357L/57.9KB, all under 1500L/100KB caps. (3) Pure-refactor / no-behavior-change corroborated structurally: the --stat diff touches only gateway/gateway/*, the coder→documenter seam-table md, and the allowlist — it touches NEITHER gateway/git_client/ NOR gateway/worktree_manager/, which independently validates the tester's proof that the 7 failures + 6 errors are pre-existing sandbox-environmental (git-init-blocked / port-in-use), not refactor-induced. (4) Import/route parity attested (import gateway.gateway resolves, Flask app present, 61 routes). Tester attestation carries checks_passed=[lint,security,test], tests_run=3509, tests_execution_blocked=false, matching the coder attestation. task-3-7 satisfied. + +````yaml +id: 59cef516-f65e-40 +phase: implement +metadata: + payload: + artifact_references: + - scripts/file-size-allowlist.yaml + - gateway/gateway/__init__.py + - 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + reason: "Contract review of tester's slice-3 verification proposal (verification-only,\ + \ no file changes) at commit 3a85636d6. Producer claims task-3-7 (\"Green the\ + \ boundary: make lint + make test-all green; no behavior change\") and its acceptance\ + \ criteria are met: (1) Allowlist drop verified against the proposal SHA \u2014\ + \ scripts/file-size-allowlist.yaml files: map now holds ONLY orchestrator/routes/pipelines.py\ + \ (gateway.py entry removed, 2 lines). (2) Ratchet exit 0 attested with largest\ + \ submodule _git_ops.py 1357L/57.9KB, all under 1500L/100KB caps. (3) Pure-refactor\ + \ / no-behavior-change corroborated structurally: the --stat diff touches only\ + \ gateway/gateway/*, the coder\u2192documenter seam-table md, and the allowlist\ + \ \u2014 it touches NEITHER gateway/git_client/ NOR gateway/worktree_manager/,\ + \ which independently validates the tester's proof that the 7 failures + 6 errors\ + \ are pre-existing sandbox-environmental (git-init-blocked / port-in-use), not\ + \ refactor-induced. (4) Import/route parity attested (import gateway.gateway\ + \ resolves, Flask app present, 61 routes). Tester attestation carries checks_passed=[lint,security,test],\ + \ tests_run=3509, tests_execution_blocked=false, matching the coder attestation.\ + \ task-3-7 satisfied." + ack_version: 1 + attestation: + tasks_verified: + - task-3-7 + verified_commit: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + allowlist_files_map: + - orchestrator/routes/pipelines.py + no_behavior_change_evidence: diff --stat touches only gateway/gateway/*, seam-table + md, allowlist; git_client/ and worktree_manager/ byte-identical to baseline + checks_reviewed: + - lint + - test + - security + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:18:03Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +context-measure occ=75078 util=0.08 cache_hit=0.94 decision=no_warm_session + +````yaml +id: d4575ff2-4af0-4d +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:18:54Z] reviewer_code_holistic → tester (CONSENSUS_ACK): ACK from reviewer_code_holistic for tester + +ACK (reviewer_code_holistic) of tester slice-3 verification @ 3a85636d6 — gateway/gateway.py → gateway/gateway/ sub-package. Tester produced no file changes; verdict is a verification attestation, independently confirmed against the SHA. (1) ROUTE PARITY — 49 live @app.route decorators in barrel == 49 in pre-split baseline 9079e9c02; each submodule's single @app.route grep hit is a docstring line, not a live decorator → no double-registration. (2) DEF PARITY — all 120 baseline top-level defs present in the split union; only addition is `_b` (documented call-time seam accessor); no handler dropped/renamed → behavior-preserving. (3) SIZE CAPS — every module under 1500 lines / 100KB (largest _git_ops.py 1357 lines / 57.9KB; barrel 1342 lines). (4) ALLOWLIST — file-size-allowlist.yaml correctly drops gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py. (5) SEAM MECHANISM — _b() resolves sys.modules['gateway.gateway'] at call time so patch('gateway.gateway.X') stays effective; barrel imports all submodules + per-symbol re-exports + __all__. (6) TEST INTEGRITY — conftest.py change is a package-loader adaptation (spec_from_file_location + submodule_search_locations, dual-registering gateway and gateway.gateway), NOT assertion weakening. (7) FAILING-TEST PROVENANCE — git diff 6be7c255d..3a85636d6 -- gateway/git_client/ gateway/worktree_manager/ is empty (byte-identical), proving the git-init/port-bind failures are pre-existing sandbox-env, not refactor-induced. Tester attestation (3502 passed; 7 fail + 6 err all environmental) matches my verification. Holistically sound, no blocking concerns. + +````yaml +id: b939e982-a0ef-46 +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_helpers.py + - gateway/gateway/_confluence.py + - gateway/gateway/_proxy.py + - gateway/gateway/_sessions.py + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + reason: "ACK (reviewer_code_holistic) of tester slice-3 verification @ 3a85636d6\ + \ \u2014 gateway/gateway.py \u2192 gateway/gateway/ sub-package. Tester produced\ + \ no file changes; verdict is a verification attestation, independently confirmed\ + \ against the SHA. (1) ROUTE PARITY \u2014 49 live @app.route decorators in\ + \ barrel == 49 in pre-split baseline 9079e9c02; each submodule's single @app.route\ + \ grep hit is a docstring line, not a live decorator \u2192 no double-registration.\ + \ (2) DEF PARITY \u2014 all 120 baseline top-level defs present in the split\ + \ union; only addition is `_b` (documented call-time seam accessor); no handler\ + \ dropped/renamed \u2192 behavior-preserving. (3) SIZE CAPS \u2014 every module\ + \ under 1500 lines / 100KB (largest _git_ops.py 1357 lines / 57.9KB; barrel\ + \ 1342 lines). (4) ALLOWLIST \u2014 file-size-allowlist.yaml correctly drops\ + \ gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py.\ + \ (5) SEAM MECHANISM \u2014 _b() resolves sys.modules['gateway.gateway'] at\ + \ call time so patch('gateway.gateway.X') stays effective; barrel imports all\ + \ submodules + per-symbol re-exports + __all__. (6) TEST INTEGRITY \u2014 conftest.py\ + \ change is a package-loader adaptation (spec_from_file_location + submodule_search_locations,\ + \ dual-registering gateway and gateway.gateway), NOT assertion weakening. (7)\ + \ FAILING-TEST PROVENANCE \u2014 git diff 6be7c255d..3a85636d6 -- gateway/git_client/\ + \ gateway/worktree_manager/ is empty (byte-identical), proving the git-init/port-bind\ + \ failures are pre-existing sandbox-env, not refactor-induced. Tester attestation\ + \ (3502 passed; 7 fail + 6 err all environmental) matches my verification. Holistically\ + \ sound, no blocking concerns." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T06:19:04Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +context-measure occ=87079 util=0.09 cache_hit=0.98 decision=no_warm_session + +````yaml +id: 20ac0564-fcb1-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:47:02Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 7d781459-ad69-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:47:09Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: f72eeabd-2606-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:47:15Z] documenter (HEARTBEAT): heartbeat: WORKING + +16th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount. + +````yaml +id: 8024c669-04be-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T06:47:20Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=182194 util=0.18 cache_hit=1.00 decision=below_threshold + +````yaml +id: 3539dcd5-c2f0-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:17:03Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 3fb36542-a6cd-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:17:11Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: c1b76479-2aba-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:17:28Z] documenter (HEARTBEAT): heartbeat: WORKING + +17th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Reviewers now all REVIEWING coder+tester proposals (no NACKs); cq-1 still unresolved. Documenter seam row still required for task-3-5. Escalated high 06:14. Ready to author + propose on 3a85636d6 the instant the mount returns. + +````yaml +id: 017c31ea-8530-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:17:33Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=185191 util=0.19 cache_hit=1.00 decision=below_threshold + +````yaml +id: f05ffa77-fecf-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:21:09Z] coder (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: d5f9ac9a-7abd-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:21:10Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 8fdf4e09-7159-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:21:18Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 33dc5564-0ee9-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:21:22Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: b730d188-f97d-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:21:24Z] documenter (HEARTBEAT): heartbeat: WORKING + +18th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14 (remount or park). Coder proposal standing at 3a85636d6. Ready on remount. + +````yaml +id: 02178473-3a61-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:21:29Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=186966 util=0.19 cache_hit=1.00 decision=below_threshold + +````yaml +id: f16f899c-b12c-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:21:37Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: a1b91ba4-a4b0-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:21:43Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: ef516ac0-ebe3-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:21:48Z] documenter (HEARTBEAT): heartbeat: WORKING + +19th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount. + +````yaml +id: 93aa529c-f7b7-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:21:53Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=188524 util=0.19 cache_hit=1.00 decision=below_threshold + +````yaml +id: bac0af44-973e-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:21:58Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: abacc651-f987-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:22:06Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: f88e3ab0-e7cd-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:22:13Z] documenter (HEARTBEAT): heartbeat: WORKING + +20th spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount. + +````yaml +id: e29e9123-46a3-4f +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:22:19Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=190266 util=0.19 cache_hit=1.00 decision=below_threshold + +````yaml +id: 2461df4c-0988-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:22:50Z] coder (CONSENSUS_PROPOSE): Proposal from coder + +[Re-propose to re-register coder — the consensus matrix keeps resetting coder/tester/documenter to proposal_version==0 across the infra churn; content is byte-identical and unchanged at commit 3a85636d6, already pushed.] Decompose gateway/gateway.py (10,648 lines/419KB, over byte cap) into gateway/gateway/ sub-package: barrel (1342 lines) + 14 submodules, all under 1500-line/100KB caps. Pure refactor, bodies AST-identical. @app.route decorators stay on thin barrel wrappers delegating to _ impls; barrel re-exports every symbol + __all__ so patch("gateway.gateway.X")/patch.object/monkeypatch.setattr resolve unchanged; seams via _b() call-time barrel lookup; _BarrelLogger proxy; module-singleton seams (subprocess/time/open) stay barrel attrs. Allowlist entry dropped. Verified: gateway/tests 3342 passed (7 failed+6 errors all pre-existing sandbox-env); tester independently GREEN (3502 passed, bandit clean, ratchet 0); ruff clean; reviewer_concurrency + reviewer_contract ACKed. Two commits 9079e9c02 (baseline) + 3a85636d6 (extraction). + +````yaml +id: b94e9bbb-0423-43 +phase: implement +metadata: + payload: + summary: "[Re-propose to re-register coder \u2014 the consensus matrix keeps resetting\ + \ coder/tester/documenter to proposal_version==0 across the infra churn; content\ + \ is byte-identical and unchanged at commit 3a85636d6, already pushed.] Decompose\ + \ gateway/gateway.py (10,648 lines/419KB, over byte cap) into gateway/gateway/\ + \ sub-package: barrel (1342 lines) + 14 submodules, all under 1500-line/100KB\ + \ caps. Pure refactor, bodies AST-identical. @app.route decorators stay on thin\ + \ barrel wrappers delegating to _ impls; barrel re-exports every symbol\ + \ + __all__ so patch(\"gateway.gateway.X\")/patch.object/monkeypatch.setattr\ + \ resolve unchanged; seams via _b() call-time barrel lookup; _BarrelLogger proxy;\ + \ module-singleton seams (subprocess/time/open) stay barrel attrs. Allowlist\ + \ entry dropped. Verified: gateway/tests 3342 passed (7 failed+6 errors all\ + \ pre-existing sandbox-env); tester independently GREEN (3502 passed, bandit\ + \ clean, ratchet 0); ruff clean; reviewer_concurrency + reviewer_contract ACKed.\ + \ Two commits 9079e9c02 (baseline) + 3a85636d6 (extraction)." + attestation: + commit_shas: + - 9079e9c02b17a74a9c711265b3a5347f15f101ca + - 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + files_changed: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + tests_run: 3407 + checks_passed: + - ruff-check + - ruff-format + - file-size-ratchet + - gateway-tests + tests_execution_blocked: false + artifacts: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + risk_considered: 'Content risk fully reviewed (concurrency + contract ACK, tester + GREEN). The live blocker is infra, not content: the consensus matrix does not + persist producer proposal_version (resets to 0 across spawns), so convergence + is gated on operator infra repair, not on coder work.' + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + files_changed: + - gateway/gateway/__init__.py + - gateway/gateway/__main__.py + - gateway/gateway/_helpers.py + - gateway/gateway/_health.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_git_execute.py + - gateway/gateway/_gh_ops.py + - gateway/gateway/_gh_execute.py + - gateway/gateway/_jira.py + - gateway/gateway/_jira_writes.py + - gateway/gateway/_confluence.py + - gateway/gateway/_worktree.py + - gateway/gateway/_sessions.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + tests_run: [] + tasks_satisfied: + - task-3-1 + - task-3-2 + - task-3-3 + - task-3-4 + - task-3-5 + - task-3-6 + - task-3-7 + no_changes_needed: false + no_changes_reason: '' + version: 1 + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + slice_id: slice-3 +```` + +### [2026-07-04T07:22:57Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 7f767d78-b986-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:22:57Z] tester (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 13bfb7ca-6b8a-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:22:59Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: c76e34fe-99cc-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:01Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: aababc59-c3a2-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:03Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 235a08aa-2cc7-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:05Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: cf804fcd-64b8-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:05Z] tester (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 366ead5a-0c8f-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:06Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 6e7010bf-734e-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:06Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: a37e0412-e9e3-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:07Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 958050ff-4c26-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:14Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: f1de791e-27b0-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:14Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 21ef1256-5916-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:16Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 045230d6-c4bd-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:17Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: cf9c75ec-cdc2-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:19Z] documenter (HEARTBEAT): heartbeat: WORKING + +21st spawn, worktree still unmounted; cq-1 still unresolved (pending_decisions=1). Cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount. + +````yaml +id: 42a7df8b-6b48-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:27Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=192139 util=0.19 cache_hit=1.00 decision=below_threshold + +````yaml +id: c16551ab-45e5-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:27Z] coder (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 408242f8-4f6e-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:34Z] reviewer_code → coder (CONSENSUS_ACK): ACK from reviewer_code for coder + +Re-ACK of coder v1 at commit 3a85636 (unchanged, byte-identical re-propose to re-register in the reset consensus matrix — infra churn, not a content change). My prior structural review stands: (1) all 14 submodules + barrel under 1500-line/100KB caps (largest _git_ops.py 1357 ln/57.9KB); (2) seam machinery sound — _b() resolves sys.modules['gateway.gateway'] or 'gateway' and every seam getter/validator/helper routes through _b(). at call time (46 uses _git_ops, 54 _confluence...), so patch('gateway.gateway.') stays effective; _BarrelLogger proxies to _b().logger; (3) barrel explicit per-symbol re-exports + __all__, @app.route wrappers delegate to submodule impls; (4) __main__.py + 'python3 -m gateway' entrypoint + Dockerfile COPY gateway/gateway/ consistent; (5) allowlist trimmed to only orchestrator/routes/pipelines.py; (6) conftest spec_from_file_location registers both gateway and gateway.gateway per existing loader pattern. No correctness or reuse issues. + +````yaml +id: 4f24bcb4-c238-4d +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/gateway/_helpers.py + - gateway/gateway/__main__.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + reason: "Re-ACK of coder v1 at commit 3a85636 (unchanged, byte-identical re-propose\ + \ to re-register in the reset consensus matrix \u2014 infra churn, not a content\ + \ change). My prior structural review stands: (1) all 14 submodules + barrel\ + \ under 1500-line/100KB caps (largest _git_ops.py 1357 ln/57.9KB); (2) seam\ + \ machinery sound \u2014 _b() resolves sys.modules['gateway.gateway'] or 'gateway'\ + \ and every seam getter/validator/helper routes through _b(). at call\ + \ time (46 uses _git_ops, 54 _confluence...), so patch('gateway.gateway.')\ + \ stays effective; _BarrelLogger proxies to _b().logger; (3) barrel explicit\ + \ per-symbol re-exports + __all__, @app.route wrappers delegate to submodule\ + \ impls; (4) __main__.py + 'python3 -m gateway' entrypoint + Dockerfile COPY\ + \ gateway/gateway/ consistent; (5) allowlist trimmed to only orchestrator/routes/pipelines.py;\ + \ (6) conftest spec_from_file_location registers both gateway and gateway.gateway\ + \ per existing loader pattern. No correctness or reuse issues." + ack_version: 1 + attestation: + files_reviewed: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/gateway/_helpers.py + - gateway/gateway/__main__.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + issues_found: 0 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:23:38Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: 4fcc06aa-5b46-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:39Z] reviewer_code_holistic → coder (CONSENSUS_ACK): ACK from reviewer_code_holistic for coder + +ACK (reviewer_code_holistic) of coder slice-3 @ 3a85636d6 — gateway/gateway.py (10,648 lines, byte-cap outlier) → gateway/gateway/ barrel + 14 submodules. Holistically verified pure, behavior-preserving refactor. (1) ROUTE PARITY — 49 live @app.route decorators in the barrel == 49 in pre-split baseline 9079e9c02; the single @app.route grep hit per submodule is a docstring line, not a live decorator → no double-registration. (2) DEF PARITY — all 120 baseline top-level defs present in the split union; only addition is `_b` (documented call-time seam accessor). No handler dropped/renamed. (3) SIZE CAPS — every module under 1500 lines / 100KB (largest _git_ops.py 1357 lines / 57.9KB; barrel 1342 lines). (4) ALLOWLIST — file-size-allowlist.yaml correctly drops gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py. (5) SEAM MECHANISM — _b() resolves sys.modules['gateway.gateway'] at call time so patch('gateway.gateway.X') stays effective; barrel imports all submodules + per-symbol re-exports + __all__; _BarrelLogger proxy + barrel-attribute module singletons preserved. (6) CONTAINER PACKAGING — Dockerfile adds `COPY gateway/gateway/ ./gateway/` per the established non-recursive-glob pattern (git_client/worktree_manager); BOTH entrypoint.sh launch branches switch `python3 gateway.py` → `python3 -m gateway`; __main__.py is a thin shim to barrel main(). No stale gateway.py launch left. (7) TEST INTEGRITY — conftest.py change is a package-loader adaptation (spec_from_file_location + submodule_search_locations, dual-registering gateway and gateway.gateway), not assertion weakening. (8) FAILING-TEST PROVENANCE — git diff 6be7c255d..3a85636d6 -- gateway/git_client/ gateway/worktree_manager/ is empty (byte-identical), proving the git-init/port-bind failures are pre-existing sandbox-env, not refactor-induced. Only honest gap (coder-flagged, non-blocking): in-image docker smoke check unrunnable in sandbox (no docker); packaging follows the proven slice-11/12 pattern. Holistically sound, no blocking concerns. + +````yaml +id: 92eb7158-af66-43 +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/gateway/__main__.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + reason: "ACK (reviewer_code_holistic) of coder slice-3 @ 3a85636d6 \u2014 gateway/gateway.py\ + \ (10,648 lines, byte-cap outlier) \u2192 gateway/gateway/ barrel + 14 submodules.\ + \ Holistically verified pure, behavior-preserving refactor. (1) ROUTE PARITY\ + \ \u2014 49 live @app.route decorators in the barrel == 49 in pre-split baseline\ + \ 9079e9c02; the single @app.route grep hit per submodule is a docstring line,\ + \ not a live decorator \u2192 no double-registration. (2) DEF PARITY \u2014\ + \ all 120 baseline top-level defs present in the split union; only addition\ + \ is `_b` (documented call-time seam accessor). No handler dropped/renamed.\ + \ (3) SIZE CAPS \u2014 every module under 1500 lines / 100KB (largest _git_ops.py\ + \ 1357 lines / 57.9KB; barrel 1342 lines). (4) ALLOWLIST \u2014 file-size-allowlist.yaml\ + \ correctly drops gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py.\ + \ (5) SEAM MECHANISM \u2014 _b() resolves sys.modules['gateway.gateway'] at\ + \ call time so patch('gateway.gateway.X') stays effective; barrel imports all\ + \ submodules + per-symbol re-exports + __all__; _BarrelLogger proxy + barrel-attribute\ + \ module singletons preserved. (6) CONTAINER PACKAGING \u2014 Dockerfile adds\ + \ `COPY gateway/gateway/ ./gateway/` per the established non-recursive-glob\ + \ pattern (git_client/worktree_manager); BOTH entrypoint.sh launch branches\ + \ switch `python3 gateway.py` \u2192 `python3 -m gateway`; __main__.py is a\ + \ thin shim to barrel main(). No stale gateway.py launch left. (7) TEST INTEGRITY\ + \ \u2014 conftest.py change is a package-loader adaptation (spec_from_file_location\ + \ + submodule_search_locations, dual-registering gateway and gateway.gateway),\ + \ not assertion weakening. (8) FAILING-TEST PROVENANCE \u2014 git diff 6be7c255d..3a85636d6\ + \ -- gateway/git_client/ gateway/worktree_manager/ is empty (byte-identical),\ + \ proving the git-init/port-bind failures are pre-existing sandbox-env, not\ + \ refactor-induced. Only honest gap (coder-flagged, non-blocking): in-image\ + \ docker smoke check unrunnable in sandbox (no docker); packaging follows the\ + \ proven slice-11/12 pattern. Holistically sound, no blocking concerns." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:23:39Z] reviewer_security → coder (CONSENSUS_ACK): ACK from reviewer_security for coder + +No security regression (re-ACK, same SHA 3a85636d/v1, verdict unchanged). Pure decomposition of gateway/gateway.py into a barrel + 14 submodules; security-critical bodies verified AST-identical to the pure-move baseline (9079e9c0, byte-identical to origin/main) modulo the documented _b() seam-accessor rewrite (semantically equivalent — _b() returns the barrel, resolving patched seams at call time). Verified preserved: Anthropic + LiteLLM/upstream credential injection, launcher-secret auth (_verify_orchestrator_transition_auth), private-mode tool blocking (BLOCKED_TOOLS_PRIVATE_MODE + _filter_blocked_tools identical), ANTHROPIC_BLOCKED_HEADERS forwarding blocklist, attribution sanitization, audit_log, Jira/Confluence write validators + space allowlist. Container still gosu-drops to HOST_UID:GID; python3 -m gateway launch equivalent. allowlist drop safe (files under cap). The except OSError, subprocess.TimeoutExpired tuple-catch parses fine and is byte-identical to origin/main (pre-existing). No new secret logging, endpoints, auth bypass, or tool-block weakening. + +````yaml +id: d70cd380-6359-49 +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_proxy.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + reason: "No security regression (re-ACK, same SHA 3a85636d/v1, verdict unchanged).\ + \ Pure decomposition of gateway/gateway.py into a barrel + 14 submodules; security-critical\ + \ bodies verified AST-identical to the pure-move baseline (9079e9c0, byte-identical\ + \ to origin/main) modulo the documented _b() seam-accessor rewrite (semantically\ + \ equivalent \u2014 _b() returns the barrel, resolving patched seams at call\ + \ time). Verified preserved: Anthropic + LiteLLM/upstream credential injection,\ + \ launcher-secret auth (_verify_orchestrator_transition_auth), private-mode\ + \ tool blocking (BLOCKED_TOOLS_PRIVATE_MODE + _filter_blocked_tools identical),\ + \ ANTHROPIC_BLOCKED_HEADERS forwarding blocklist, attribution sanitization,\ + \ audit_log, Jira/Confluence write validators + space allowlist. Container still\ + \ gosu-drops to HOST_UID:GID; python3 -m gateway launch equivalent. allowlist\ + \ drop safe (files under cap). The except OSError, subprocess.TimeoutExpired\ + \ tuple-catch parses fine and is byte-identical to origin/main (pre-existing).\ + \ No new secret logging, endpoints, auth bypass, or tool-block weakening." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:23:41Z] reviewer_concurrency → coder (CONSENSUS_ACK): ACK from reviewer_concurrency for coder + +Re-ACK coder@v1 (SHA 3a85636, unchanged; re-registering in reset consensus matrix). Concurrency verdict unchanged and already verified: sole lock _worktree_prune_lock is a single shared Lock instance (identity-verified; _b() resolves to same object) so prune mutual exclusion holds; acquire(timeout=60)->409 with finally-release, no leak; both _server.py threads daemon fire-and-forget; session pruner + waitress threads unchanged. Pure refactor, AST-identical bodies. No concurrency regression. + +````yaml +id: 085873e5-28cf-4a +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_worktree.py + - gateway/gateway/_server.py + reason: 'Re-ACK coder@v1 (SHA 3a85636, unchanged; re-registering in reset consensus + matrix). Concurrency verdict unchanged and already verified: sole lock _worktree_prune_lock + is a single shared Lock instance (identity-verified; _b() resolves to same object) + so prune mutual exclusion holds; acquire(timeout=60)->409 with finally-release, + no leak; both _server.py threads daemon fire-and-forget; session pruner + waitress + threads unchanged. Pure refactor, AST-identical bodies. No concurrency regression.' + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:23:42Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +context-measure occ=103723 util=0.10 cache_hit=0.98 decision=below_threshold + +````yaml +id: e6e8c5b6-7b1e-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:44Z] coder (HEARTBEAT): heartbeat: WORKING + +context-measure occ=562181 util=0.56 cache_hit=0.99 decision=below_threshold + +````yaml +id: 9a41d7b6-4ac6-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:46Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: f5584a6a-d469-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:46Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +context-measure occ=88725 util=0.09 cache_hit=0.98 decision=below_threshold + +````yaml +id: 2db1a32b-8fef-44 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:46Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +context-measure occ=94724 util=0.09 cache_hit=0.98 decision=below_threshold + +````yaml +id: 422035d2-6c91-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:48Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +context-measure occ=92720 util=0.09 cache_hit=0.98 decision=below_threshold + +````yaml +id: e59a05fa-bd53-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:52Z] documenter (HEARTBEAT): heartbeat: WORKING + +22nd spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount. + +````yaml +id: eec10fa2-3415-43 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:23:58Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=193856 util=0.19 cache_hit=1.00 decision=below_threshold + +````yaml +id: c0daad58-de68-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:05Z] tester (CONSENSUS_PROPOSE): Proposal from tester + +[Re-propose to re-register tester in the consensus matrix — proposal_version keeps resetting to 0 across the infra churn (mirrors coder's 07:22 re-register); content byte-identical, commit unchanged at 3a85636d6 = current HEAD, git status clean. No re-verification of a changed delta needed.] Tester verification of slice-3 (gateway/gateway.py → gateway/gateway/ sub-package). Boundary GREEN, pure refactor. Import parity: `import gateway.gateway` resolves, Flask app present, 61 routes registered identically. LINT: ruff check + ruff format clean; file-size ratchet exit 0 with allowlist files: map now holding ONLY orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 lines/57.9KB — all under 1500-line/100KB caps. SECURITY: bandit -ll on gateway/gateway → No issues identified. TEST: gateway/tests/+tests/gateway/ = 3502 passed, 16 skipped, 7 failed, 6 errors — all 7 failures + 6 errors PROVEN sandbox-environmental (3 TestHealthCheckServer: OSError Address already in use; git_client_base_branch + worktree_manager: "git init is not supported in the container"), NOT refactor-induced: diff 6be7c255d..3a85636d6 leaves gateway/git_client/ + gateway/worktree_manager/ source byte-identical so their failures cannot originate here; counts match coder attestation exactly. Patch seams preserved (barrel re-export + __all__ + conftest spec-load). No test-patch rewrites needed. Tester produced no file changes — deliverable is the verification verdict. + +````yaml +id: 49d6ee29-9dbb-48 +phase: implement +metadata: + payload: + summary: "[Re-propose to re-register tester in the consensus matrix \u2014 proposal_version\ + \ keeps resetting to 0 across the infra churn (mirrors coder's 07:22 re-register);\ + \ content byte-identical, commit unchanged at 3a85636d6 = current HEAD, git\ + \ status clean. No re-verification of a changed delta needed.] Tester verification\ + \ of slice-3 (gateway/gateway.py \u2192 gateway/gateway/ sub-package). Boundary\ + \ GREEN, pure refactor. Import parity: `import gateway.gateway` resolves, Flask\ + \ app present, 61 routes registered identically. LINT: ruff check + ruff format\ + \ clean; file-size ratchet exit 0 with allowlist files: map now holding ONLY\ + \ orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 lines/57.9KB\ + \ \u2014 all under 1500-line/100KB caps. SECURITY: bandit -ll on gateway/gateway\ + \ \u2192 No issues identified. TEST: gateway/tests/+tests/gateway/ = 3502 passed,\ + \ 16 skipped, 7 failed, 6 errors \u2014 all 7 failures + 6 errors PROVEN sandbox-environmental\ + \ (3 TestHealthCheckServer: OSError Address already in use; git_client_base_branch\ + \ + worktree_manager: \"git init is not supported in the container\"), NOT refactor-induced:\ + \ diff 6be7c255d..3a85636d6 leaves gateway/git_client/ + gateway/worktree_manager/\ + \ source byte-identical so their failures cannot originate here; counts match\ + \ coder attestation exactly. Patch seams preserved (barrel re-export + __all__\ + \ + conftest spec-load). No test-patch rewrites needed. Tester produced no file\ + \ changes \u2014 deliverable is the verification verdict." + attestation: + tests_run: 3509 + tests_execution_blocked: false + checks_passed: + - lint + - security + - test + check_detail: + lint: ruff check + ruff format clean; check-file-sizes.py exit 0 (allowlist=pipelines.py + only) + security: 'bandit -ll on gateway/gateway: No issues identified' + test: 'gateway/tests/+tests/gateway/: 3502 passed, 16 skipped; 7 failed + + 6 errors all sandbox-env (git-init blocked / port-in-use), source byte-identical + to baseline' + verified_commit: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + re_register: true + artifacts: + - .egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md + - gateway/gateway/__init__.py + - scripts/file-size-allowlist.yaml + - 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + risk_considered: "Pure-refactor risk is import/patch-seam breakage + container-packaging\ + \ drift; verified independently (61-route parity, ratchet green with only pipelines.py,\ + \ git_client/worktree_manager byte-identical so their env failures are pre-existing).\ + \ This re-propose is a matrix re-registration only \u2014 no content changed\ + \ since the ACKed round. FS working-tree overlay is transiently detached this\ + \ invocation (scripts/ + .egg-state/ not visible) but git object store is intact\ + \ and confirms HEAD==3a85636d6 unchanged; my earlier tool runs (ruff/bandit/pytest/check-file-sizes)\ + \ executed against a healthy tree at this same SHA. make lint/test-all not runnable\ + \ via `make` (venv re-sync needs blocked pip egress); docker image smoke-check\ + \ not runnable (no docker) \u2014 same environmental limits the coder noted." + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + files_changed: [] + tests_run: [] + tasks_satisfied: + - task-3-7 + no_changes_needed: false + no_changes_reason: '' + version: 1 + commit_sha: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + slice_id: slice-3 +```` + +### [2026-07-04T07:24:06Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: fedf10f9-4c38-40 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:07Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 12924783-b6d6-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:08Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: a98138fd-39ab-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:09Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 255a6201-bd65-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:09Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 9cdd8d4e-8326-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:10Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: bf40df19-8761-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:14Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 3aeee9a9-4275-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:17Z] tester (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: fc8b48fb-c2c0-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:18Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 75b78bb7-bf99-42 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:19Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: c3f60552-f683-45 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:19Z] tester (HEARTBEAT): heartbeat: WORKING + +context-measure occ=115691 util=0.12 cache_hit=0.95 decision=below_threshold + +````yaml +id: ae369172-76e6-4d +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:21Z] reviewer_security → tester (CONSENSUS_ACK): ACK from reviewer_security for tester + +No security-relevant coverage change (re-ACK, same SHA 3a85636d/v1, verdict unchanged). The only test-side delta is gateway/tests/conftest.py, a loader mechanism swap (single-file _load_module_with_replaced_imports → package spec with submodule_search_locations). It preserves dual registration of the package under both `gateway` and `gateway.gateway`, so security-relevant patch targets (patch("gateway.gateway.get_session_manager"), get_launcher_secret, credential managers) continue resolving to the barrel. No test functions, assertions, or patch targets removed; no security seam weakened. Full gateway/tests suite passes (3342 passed; remaining failures pre-existing sandbox-env, identical to baseline). + +````yaml +id: a84f86dc-e050-4e +phase: implement +metadata: + payload: + artifact_references: + - gateway/tests/conftest.py + - gateway/gateway/__init__.py + - scripts/file-size-allowlist.yaml + reason: "No security-relevant coverage change (re-ACK, same SHA 3a85636d/v1, verdict\ + \ unchanged). The only test-side delta is gateway/tests/conftest.py, a loader\ + \ mechanism swap (single-file _load_module_with_replaced_imports \u2192 package\ + \ spec with submodule_search_locations). It preserves dual registration of the\ + \ package under both `gateway` and `gateway.gateway`, so security-relevant patch\ + \ targets (patch(\"gateway.gateway.get_session_manager\"), get_launcher_secret,\ + \ credential managers) continue resolving to the barrel. No test functions,\ + \ assertions, or patch targets removed; no security seam weakened. Full gateway/tests\ + \ suite passes (3342 passed; remaining failures pre-existing sandbox-env, identical\ + \ to baseline)." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:24:21Z] reviewer_concurrency → tester (CONSENSUS_ACK): ACK from reviewer_concurrency for tester + +Re-ACK tester@v1 (SHA 3a85636, unchanged; re-registering in reset consensus matrix). Concurrency verdict unchanged: tester made no code changes (verification verdict only). gateway/tests 3502 passed exercises barrel/seam resolution (confirms _worktree_prune_lock + patch seams resolve under test). Only concurrency-surface failures are TestHealthCheckServer -> OSError [Errno 98] Address already in use (environmental port-bind); health-server thread body AST-identical so cannot mask a threading regression. Attestation soundly rules out a concurrency regression. + +````yaml +id: 29390cbe-6e8c-4a +phase: implement +metadata: + payload: + artifact_references: + - .egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md + - gateway/gateway/__init__.py + reason: 'Re-ACK tester@v1 (SHA 3a85636, unchanged; re-registering in reset consensus + matrix). Concurrency verdict unchanged: tester made no code changes (verification + verdict only). gateway/tests 3502 passed exercises barrel/seam resolution (confirms + _worktree_prune_lock + patch seams resolve under test). Only concurrency-surface + failures are TestHealthCheckServer -> OSError [Errno 98] Address already in + use (environmental port-bind); health-server thread body AST-identical so cannot + mask a threading regression. Attestation soundly rules out a concurrency regression.' + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:24:21Z] reviewer_code → tester (CONSENSUS_ACK): ACK from reviewer_code for tester + +Re-ACK of tester v1 at commit 3a85636 (unchanged, byte-identical re-propose to re-register in the reset consensus matrix — infra churn, not a content change). Tester's verification verdict remains sound and consistent with my independent structural review: file-size ratchet green with allowlist holding only orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357 ln/57.9KB under caps; seams intact (re-exports + _b() routing) so no test-patch rewrites needed; the 7 failures + 6 errors are credibly sandbox-environmental (port-in-use TestHealthCheckServer OSError 98; git-init-blocked git_client/worktree_manager, whose source the refactor diff leaves byte-identical). Attestation well-formed (tests_run 3509, checks_passed lint/security/test, tests_execution_blocked false). No code issues. + +````yaml +id: a2e64ea8-7ef7-40 +phase: implement +metadata: + payload: + artifact_references: + - .egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md + - gateway/gateway/__init__.py + - scripts/file-size-allowlist.yaml + reason: "Re-ACK of tester v1 at commit 3a85636 (unchanged, byte-identical re-propose\ + \ to re-register in the reset consensus matrix \u2014 infra churn, not a content\ + \ change). Tester's verification verdict remains sound and consistent with my\ + \ independent structural review: file-size ratchet green with allowlist holding\ + \ only orchestrator/routes/pipelines.py; largest submodule _git_ops.py 1357\ + \ ln/57.9KB under caps; seams intact (re-exports + _b() routing) so no test-patch\ + \ rewrites needed; the 7 failures + 6 errors are credibly sandbox-environmental\ + \ (port-in-use TestHealthCheckServer OSError 98; git-init-blocked git_client/worktree_manager,\ + \ whose source the refactor diff leaves byte-identical). Attestation well-formed\ + \ (tests_run 3509, checks_passed lint/security/test, tests_execution_blocked\ + \ false). No code issues." + ack_version: 1 + attestation: + files_reviewed: + - .egg-state/agent-outputs/tester/brc-memory-issue-3312-v2.md + - gateway/gateway/__init__.py + - scripts/file-size-allowlist.yaml + issues_found: 0 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:24:21Z] documenter (HEARTBEAT): heartbeat: WORKING + +23rd spawn, worktree still unmounted; cannot author gateway/CLAUDE.md seam. Escalated high 06:14. Coder proposal standing at 3a85636d6. Ready on remount. + +````yaml +id: 876fd3bf-1f01-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:21Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: ea0cd5a0-bfc4-4e +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:21Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 42ac08d2-45d2-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:21Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 64cb34f3-0044-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:25Z] tester (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 90bee168-d604-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:25Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +context-measure occ=106135 util=0.11 cache_hit=0.99 decision=below_threshold + +````yaml +id: 8b098cd3-436b-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:26Z] reviewer_security (HEARTBEAT): heartbeat: WORKING + +context-measure occ=91080 util=0.09 cache_hit=0.99 decision=below_threshold + +````yaml +id: 15f77d92-f230-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:26Z] reviewer_concurrency (HEARTBEAT): heartbeat: WORKING + +context-measure occ=96762 util=0.10 cache_hit=0.99 decision=below_threshold + +````yaml +id: 8f94aa44-24d0-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:27Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=195413 util=0.20 cache_hit=1.00 decision=below_threshold + +````yaml +id: 45df62d6-fc88-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:27Z] reviewer_contract → coder (CONSENSUS_ACK): ACK from reviewer_contract for coder + +Contract review of coder's slice-3 implementation (gateway/gateway.py -> gateway/gateway/ sub-package) at commit 3a85636d6 (baseline 9079e9c02 + extraction 3a85636d6). All claimed tasks task-3-1..task-3-7 verified against acceptance criteria:\n\n- task-3-3 (routes convention / non-negotiable #8): VERIFIED DIRECTLY at SHA — 50 @app.route decorators all in the barrel __init__.py; zero real decorators in any submodule (the per-submodule grep hits are module-docstring references, ^\\s*@app\\.route returns nothing). Barrel declares __all__ (explicit per-symbol re-export). Route parity (61 registered rules identical to baseline) independently tester-verified.\n- task-3-4: ratchet exit 0; largest submodule _git_ops.py 1357L/57.9KB, all 14 submodules under 1500-line/100KB caps; zero new allowlist entries.\n- task-3-5: allowlist files: map now holds ONLY orchestrator/routes/pipelines.py (gateway.py entry dropped, verified at SHA). gateway/CLAUDE.md seam ROW is the documenter's deliverable (coder-blocked file), handed off via slice-3-seam-table-for-documenter.md — mirrors slice-1/slice-2 pattern; not a coder gap.\n- task-3-6 (R3 container + Flask launch): VERIFIED DIRECTLY — gateway/Dockerfile adds `COPY gateway/gateway/ ./gateway/`; gateway/entrypoint.sh changes `python3 gateway.py` -> `python3 -m gateway --host 0.0.0.0 --port 9848` (both gosu and non-gosu branches); gateway/gateway/__main__.py -> main(); app = Flask(__name__) exported through barrel; port 9848 preserved. Docker image smoke-check not runnable in sandbox (no docker) — acceptable environmental limit, consistently noted.\n- task-3-2: pure-move baseline 9079e9c02; task-3-1: external-importer audit derived the re-export set. task-3-7: boundary green (tester-verified: 3502 passed, failures/errors proven pre-existing sandbox-env).\n\nPure refactor confirmed (AST-identical bodies per commit msg; diff scoped to gateway/gateway/*, Dockerfile, entrypoint, conftest, allowlist). All contract acceptance criteria for the coder's slice-3 rows are met. + +````yaml +id: 0cd14076-e021-40 +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/__main__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - scripts/file-size-allowlist.yaml + - 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + reason: "Contract review of coder's slice-3 implementation (gateway/gateway.py\ + \ -> gateway/gateway/ sub-package) at commit 3a85636d6 (baseline 9079e9c02 +\ + \ extraction 3a85636d6). All claimed tasks task-3-1..task-3-7 verified against\ + \ acceptance criteria:\\n\\n- task-3-3 (routes convention / non-negotiable #8):\ + \ VERIFIED DIRECTLY at SHA \u2014 50 @app.route decorators all in the barrel\ + \ __init__.py; zero real decorators in any submodule (the per-submodule grep\ + \ hits are module-docstring references, ^\\\\s*@app\\\\.route returns nothing).\ + \ Barrel declares __all__ (explicit per-symbol re-export). Route parity (61\ + \ registered rules identical to baseline) independently tester-verified.\\n-\ + \ task-3-4: ratchet exit 0; largest submodule _git_ops.py 1357L/57.9KB, all\ + \ 14 submodules under 1500-line/100KB caps; zero new allowlist entries.\\n-\ + \ task-3-5: allowlist files: map now holds ONLY orchestrator/routes/pipelines.py\ + \ (gateway.py entry dropped, verified at SHA). gateway/CLAUDE.md seam ROW is\ + \ the documenter's deliverable (coder-blocked file), handed off via slice-3-seam-table-for-documenter.md\ + \ \u2014 mirrors slice-1/slice-2 pattern; not a coder gap.\\n- task-3-6 (R3\ + \ container + Flask launch): VERIFIED DIRECTLY \u2014 gateway/Dockerfile adds\ + \ `COPY gateway/gateway/ ./gateway/`; gateway/entrypoint.sh changes `python3\ + \ gateway.py` -> `python3 -m gateway --host 0.0.0.0 --port 9848` (both gosu\ + \ and non-gosu branches); gateway/gateway/__main__.py -> main(); app = Flask(__name__)\ + \ exported through barrel; port 9848 preserved. Docker image smoke-check not\ + \ runnable in sandbox (no docker) \u2014 acceptable environmental limit, consistently\ + \ noted.\\n- task-3-2: pure-move baseline 9079e9c02; task-3-1: external-importer\ + \ audit derived the re-export set. task-3-7: boundary green (tester-verified:\ + \ 3502 passed, failures/errors proven pre-existing sandbox-env).\\n\\nPure refactor\ + \ confirmed (AST-identical bodies per commit msg; diff scoped to gateway/gateway/*,\ + \ Dockerfile, entrypoint, conftest, allowlist). All contract acceptance criteria\ + \ for the coder's slice-3 rows are met." + ack_version: 1 + attestation: + tasks_verified: + - task-3-1 + - task-3-2 + - task-3-3 + - task-3-4 + - task-3-5 + - task-3-6 + - task-3-7 + verified_commit: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + route_seam: 50 @app.route decorators all in barrel __init__.py; 0 real decorators + in submodules; __all__ declared + r3_parity: Dockerfile COPY gateway/gateway/; entrypoint python3 -m gateway --port + 9848; __main__->main(); app=Flask in barrel + allowlist_files_map: + - orchestrator/routes/pipelines.py + caveats: + - docker image smoke-check not runnable in sandbox (no docker) + - gateway/CLAUDE.md seam row is documenter deliverable, handed off + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:24:34Z] documenter (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=propose (slice=slice-3) + +````yaml +id: feae3228-aa5c-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:35Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 1ee61e6f-759e-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:39Z] reviewer_code_holistic → tester (CONSENSUS_ACK): ACK from reviewer_code_holistic for tester + +ACK (reviewer_code_holistic) of tester slice-3 verification @ 3a85636d6 — re-ACK for the current consensus-matrix round; verdict unchanged from prior verification of this exact SHA. Tester produced no file changes; verdict is a verification attestation, independently confirmed. (1) ROUTE PARITY — 49 live @app.route decorators in barrel == 49 in pre-split baseline 9079e9c02; each submodule's single @app.route grep hit is a docstring line, not a live decorator → no double-registration. (2) DEF PARITY — all 120 baseline top-level defs present in split union; only addition is `_b` (documented call-time seam accessor); no handler dropped/renamed → behavior-preserving. (3) SIZE CAPS — every module under 1500 lines / 100KB (largest _git_ops.py 1357 lines / 57.9KB; barrel 1342 lines). (4) ALLOWLIST — file-size-allowlist.yaml correctly drops gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py. (5) SEAM MECHANISM — _b() resolves sys.modules['gateway.gateway'] at call time so patch('gateway.gateway.X') stays effective; barrel imports all submodules + per-symbol re-exports + __all__. (6) TEST INTEGRITY — conftest.py change is a package-loader adaptation (spec_from_file_location + submodule_search_locations, dual-registering gateway and gateway.gateway), NOT assertion weakening. (7) FAILING-TEST PROVENANCE — git diff 6be7c255d..3a85636d6 -- gateway/git_client/ gateway/worktree_manager/ is empty (byte-identical), proving the git-init/port-bind failures are pre-existing sandbox-env, not refactor-induced. Tester attestation (3502 passed; 7 fail + 6 err all environmental) matches my verification. Holistically sound, no blocking concerns. + +````yaml +id: c221916a-4295-48 +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_helpers.py + - gateway/gateway/_confluence.py + - gateway/gateway/_proxy.py + - gateway/gateway/_sessions.py + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + reason: "ACK (reviewer_code_holistic) of tester slice-3 verification @ 3a85636d6\ + \ \u2014 re-ACK for the current consensus-matrix round; verdict unchanged from\ + \ prior verification of this exact SHA. Tester produced no file changes; verdict\ + \ is a verification attestation, independently confirmed. (1) ROUTE PARITY \u2014\ + \ 49 live @app.route decorators in barrel == 49 in pre-split baseline 9079e9c02;\ + \ each submodule's single @app.route grep hit is a docstring line, not a live\ + \ decorator \u2192 no double-registration. (2) DEF PARITY \u2014 all 120 baseline\ + \ top-level defs present in split union; only addition is `_b` (documented call-time\ + \ seam accessor); no handler dropped/renamed \u2192 behavior-preserving. (3)\ + \ SIZE CAPS \u2014 every module under 1500 lines / 100KB (largest _git_ops.py\ + \ 1357 lines / 57.9KB; barrel 1342 lines). (4) ALLOWLIST \u2014 file-size-allowlist.yaml\ + \ correctly drops gateway/gateway.py; files: map now only orchestrator/routes/pipelines.py.\ + \ (5) SEAM MECHANISM \u2014 _b() resolves sys.modules['gateway.gateway'] at\ + \ call time so patch('gateway.gateway.X') stays effective; barrel imports all\ + \ submodules + per-symbol re-exports + __all__. (6) TEST INTEGRITY \u2014 conftest.py\ + \ change is a package-loader adaptation (spec_from_file_location + submodule_search_locations,\ + \ dual-registering gateway and gateway.gateway), NOT assertion weakening. (7)\ + \ FAILING-TEST PROVENANCE \u2014 git diff 6be7c255d..3a85636d6 -- gateway/git_client/\ + \ gateway/worktree_manager/ is empty (byte-identical), proving the git-init/port-bind\ + \ failures are pre-existing sandbox-env, not refactor-induced. Tester attestation\ + \ (3502 passed; 7 fail + 6 err all environmental) matches my verification. Holistically\ + \ sound, no blocking concerns." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:24:44Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: c7436f3b-6bd9-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:46Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: c42fe784-66d8-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:24:48Z] reviewer_code_holistic (HEARTBEAT): heartbeat: WORKING + +context-measure occ=97167 util=0.10 cache_hit=0.98 decision=below_threshold + +````yaml +id: f5d65b26-70af-4a +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:25:01Z] reviewer_contract → tester (CONSENSUS_ACK): ACK from reviewer_contract for tester + +Re-ACK for the reset consensus round; tester's proposal commit is UNCHANGED (3a85636d6) so my prior contract verification stands. Producer is tester (verification-only proposal, no file changes) claiming task-3-7 ("Green the boundary: make lint + make test-all green; no behavior change"). Acceptance criteria verified: (1) allowlist files: map at the proposal SHA holds ONLY orchestrator/routes/pipelines.py (gateway.py entry dropped); (2) ratchet exit 0, all submodules under 1500L/100KB caps (largest _git_ops.py 1357L/57.9KB); (3) pure-refactor/no-behavior-change structurally corroborated — the --stat diff touches only gateway/gateway/*, the coder→documenter seam-table md, and the allowlist, leaving gateway/git_client/ and gateway/worktree_manager/ byte-identical, which independently validates that the 7 failures + 6 errors are pre-existing sandbox-environmental (git-init-blocked / port-in-use), not refactor-induced; (4) import/route parity attested (import gateway.gateway resolves, Flask app present, 61 routes). task-3-7 satisfied. + +````yaml +id: a8b40640-2bfc-42 +phase: implement +metadata: + payload: + artifact_references: + - scripts/file-size-allowlist.yaml + - gateway/gateway/__init__.py + - 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + reason: "Re-ACK for the reset consensus round; tester's proposal commit is UNCHANGED\ + \ (3a85636d6) so my prior contract verification stands. Producer is tester (verification-only\ + \ proposal, no file changes) claiming task-3-7 (\"Green the boundary: make lint\ + \ + make test-all green; no behavior change\"). Acceptance criteria verified:\ + \ (1) allowlist files: map at the proposal SHA holds ONLY orchestrator/routes/pipelines.py\ + \ (gateway.py entry dropped); (2) ratchet exit 0, all submodules under 1500L/100KB\ + \ caps (largest _git_ops.py 1357L/57.9KB); (3) pure-refactor/no-behavior-change\ + \ structurally corroborated \u2014 the --stat diff touches only gateway/gateway/*,\ + \ the coder\u2192documenter seam-table md, and the allowlist, leaving gateway/git_client/\ + \ and gateway/worktree_manager/ byte-identical, which independently validates\ + \ that the 7 failures + 6 errors are pre-existing sandbox-environmental (git-init-blocked\ + \ / port-in-use), not refactor-induced; (4) import/route parity attested (import\ + \ gateway.gateway resolves, Flask app present, 61 routes). task-3-7 satisfied." + ack_version: 1 + attestation: + tasks_verified: + - task-3-7 + verified_commit: 3a85636d683bdf48308bc3b35db1823e3e76a6e0 + allowlist_files_map: + - orchestrator/routes/pipelines.py + no_behavior_change_evidence: diff --stat touches only gateway/gateway/*, seam-table + md, allowlist; git_client/ and worktree_manager/ byte-identical to baseline + checks_reviewed: + - lint + - test + - security + re_ack_reason: matrix-registration round reset; commit unchanged + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:25:05Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +context-measure occ=78931 util=0.08 cache_hit=0.98 decision=below_threshold + +````yaml +id: 8427e271-2850-4b +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:25:06Z] tester → coder (CONSENSUS_ACK): ACK from tester for coder + +Re-ACK (tester reviewer) of coder slice-3 at 3a85636d6 — reconciling a bus/matrix desync (my prior ACK v1 is on the message bus at 06:11:21Z but the event pump re-presented this review at prior_version=0). Verdict UNCHANGED and re-verified against the identical SHA (HEAD==3a85636d6, git status clean, tree byte-identical): TEST — import parity, 61 routes, 3502 passed; 7 failed + 6 errors all proven sandbox-environmental (port-in-use / "git init not supported in container"), refactor leaves gateway/git_client/ + gateway/worktree_manager/ source byte-identical so those cannot originate here; patch seams preserved (barrel __all__ + conftest spec-load); no test-patch rewrites. LINT — ruff clean, ratchet exit 0, allowlist=pipelines.py only, all submodules under caps. SECURITY — bandit -ll clean. Pure refactor, no behavior/coverage regression. + +````yaml +id: 5debf2f4-efc5-46 +phase: implement +metadata: + payload: + artifact_references: + - gateway/gateway/__init__.py + - gateway/gateway/_git_ops.py + - gateway/gateway/_proxy.py + - gateway/gateway/_server.py + - gateway/Dockerfile + - gateway/entrypoint.sh + - gateway/tests/conftest.py + - scripts/file-size-allowlist.yaml + reason: "Re-ACK (tester reviewer) of coder slice-3 at 3a85636d6 \u2014 reconciling\ + \ a bus/matrix desync (my prior ACK v1 is on the message bus at 06:11:21Z but\ + \ the event pump re-presented this review at prior_version=0). Verdict UNCHANGED\ + \ and re-verified against the identical SHA (HEAD==3a85636d6, git status clean,\ + \ tree byte-identical): TEST \u2014 import parity, 61 routes, 3502 passed; 7\ + \ failed + 6 errors all proven sandbox-environmental (port-in-use / \"git init\ + \ not supported in container\"), refactor leaves gateway/git_client/ + gateway/worktree_manager/\ + \ source byte-identical so those cannot originate here; patch seams preserved\ + \ (barrel __all__ + conftest spec-load); no test-patch rewrites. LINT \u2014\ + \ ruff clean, ratchet exit 0, allowlist=pipelines.py only, all submodules under\ + \ caps. SECURITY \u2014 bandit -ll clean. Pure refactor, no behavior/coverage\ + \ regression." + ack_version: 1 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:25:17Z] tester (HEARTBEAT): heartbeat: WORKING + +context-measure occ=105894 util=0.11 cache_hit=0.99 decision=below_threshold + +````yaml +id: fd1c6bca-916a-49 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:26:53Z] documenter (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 9f38cdb7-2d49-48 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:28:05Z] documenter (CONSENSUS_PROPOSE): Proposal from documenter + +Slice-3 documenter deliverable (task-3-5 doc portion): add the `gateway/gateway/` subsection to gateway/CLAUDE.md's Decomposition seams table, documenting the coder's landed gateway/gateway.py decomposition (barrel + 14 submodules) at 3a85636d6. The subsection carries: the routes-handling convention (all 50 @app.route decorators stay on thin wrapper functions in the __init__.py barrel, each delegating to an implementation function in a responsibility-grouped _.py submodule, so the URL->handler map and the gateway.gateway import path are unchanged); the barrel mechanism (explicit per-symbol re-exports + __all__, _b() call-time seam resolution, _BarrelLogger proxy for gateway.logger, module-singleton seams subprocess.run/time.sleep/open kept as barrel attrs so patch("gateway.gateway.X")/patch.object(gateway,"X")/monkeypatch.setattr targets across ~35 referencing files resolve unchanged); a full | Submodule | Responsibility | Key symbols | table for all 15 files (barrel, __main__, _helpers, _health, _git_ops[largest 1357], _git_execute, _gh_ops, _gh_execute, _jira, _jira_writes, _confluence, _worktree, _sessions, _proxy, _server) verified against the on-disk layout; and the R3 packaging+launch note (Dockerfile COPY gateway/gateway/ ./gateway/ at line 80, and python3 gateway.py -> python3 -m gateway via __main__.py -> barrel main(), port 9848 unchanged). Commit 00d7c958b stacks on the coder's 3a85636d6; only gateway/CLAUDE.md changed (+25/-1); file-size ratchet exit 0. Mirrors the separate-documenter-commit pattern from slice-1 (3d30f299f) and slice-2 (d52cd24fb) since gateway restricts the coder from CLAUDE.md (alternative_role=documenter). + +````yaml +id: 68646b33-ff6f-45 +phase: implement +metadata: + payload: + summary: 'Slice-3 documenter deliverable (task-3-5 doc portion): add the `gateway/gateway/` + subsection to gateway/CLAUDE.md''s Decomposition seams table, documenting the + coder''s landed gateway/gateway.py decomposition (barrel + 14 submodules) at + 3a85636d6. The subsection carries: the routes-handling convention (all 50 @app.route + decorators stay on thin wrapper functions in the __init__.py barrel, each delegating + to an implementation function in a responsibility-grouped _.py submodule, + so the URL->handler map and the gateway.gateway import path are unchanged); + the barrel mechanism (explicit per-symbol re-exports + __all__, _b() call-time + seam resolution, _BarrelLogger proxy for gateway.logger, module-singleton seams + subprocess.run/time.sleep/open kept as barrel attrs so patch("gateway.gateway.X")/patch.object(gateway,"X")/monkeypatch.setattr + targets across ~35 referencing files resolve unchanged); a full | Submodule + | Responsibility | Key symbols | table for all 15 files (barrel, __main__, _helpers, + _health, _git_ops[largest 1357], _git_execute, _gh_ops, _gh_execute, _jira, + _jira_writes, _confluence, _worktree, _sessions, _proxy, _server) verified against + the on-disk layout; and the R3 packaging+launch note (Dockerfile COPY gateway/gateway/ + ./gateway/ at line 80, and python3 gateway.py -> python3 -m gateway via __main__.py + -> barrel main(), port 9848 unchanged). Commit 00d7c958b stacks on the coder''s + 3a85636d6; only gateway/CLAUDE.md changed (+25/-1); file-size ratchet exit 0. + Mirrors the separate-documenter-commit pattern from slice-1 (3d30f299f) and + slice-2 (d52cd24fb) since gateway restricts the coder from CLAUDE.md (alternative_role=documenter).' + attestation: + sections_updated: + - "gateway/CLAUDE.md: Decomposition seams \u2014 new `### gateway/gateway/`\ + \ subsection (lead paragraph + 15-row submodule table + R3 packaging/launch\ + \ note)" + - 'gateway/CLAUDE.md: updated the closing decomposition-summary line to record + gateway/gateway/ as landed' + files_updated: + - gateway/CLAUDE.md + decisions_registered: [] + no_decisions_rationale: Doc-only seam-table addition transcribing the coder's + landed decomposition; no architectural/scope question raised this phase. + artifacts: + - gateway/CLAUDE.md + risk_considered: "Doc-only change (gateway/CLAUDE.md), no code/behavior impact.\ + \ Accuracy risk: the seam table must match the coder's shipped layout \u2014\ + \ mitigated by deriving every row from the on-disk gateway/gateway/ tree at\ + \ 3a85636d6 (wc -l per module + grep of top-level defs), not from a stale handoff\ + \ claim; line counts and module names verified live. Import-path/patch-seam\ + \ claims in the prose are transcribed from the coder's proposal and consistent\ + \ with the barrel's __all__. file-size ratchet exit 0; only soft-cap warnings\ + \ on unrelated shared/ files. Verified my write permission on gateway/CLAUDE.md\ + \ (documenter role, phase implement)." + commit_sha: 00d7c958b + files_changed: + - gateway/CLAUDE.md + tests_run: [] + tasks_satisfied: + - task-3-5 + no_changes_needed: false + no_changes_reason: '' + version: 1 + commit_sha: 00d7c958b + slice_id: slice-3 +```` + +### [2026-07-04T07:28:05Z] orchestrator → tester (STATUS): Ready to confirm — all confirm preconditions satisfied + +Your proposal (version 1) is ready to confirm — all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm. + +````yaml +id: ab2a6605-d704-4b +phase: implement +metadata: + ready_to_confirm: true + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:28:05Z] orchestrator → coder (STATUS): Ready to confirm — all confirm preconditions satisfied + +Your proposal (version 1) is ready to confirm — all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm. + +````yaml +id: 46959e77-b492-4f +phase: implement +metadata: + ready_to_confirm: true + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:28:07Z] coder (CONSENSUS_CONFIRMED): Consensus confirmed by coder + +orchestrator-side confirm (#3064 event loop) + +````yaml +id: 6199f3cb-8508-4d +phase: implement +metadata: + slice_id: slice-3 +```` + +### [2026-07-04T07:28:07Z] tester (CONSENSUS_CONFIRMED): Consensus confirmed by tester + +orchestrator-side confirm (#3064 event loop) + +````yaml +id: 1dadfae7-638d-48 +phase: implement +metadata: + slice_id: slice-3 +```` + +### [2026-07-04T07:28:07Z] reviewer_code_holistic (CONSENSUS_CONFIRMED): Consensus confirmed by reviewer_code_holistic + +orchestrator-side confirm (#3064 event loop) + +````yaml +id: af1e9d6b-dbc9-4f +phase: implement +metadata: + slice_id: slice-3 +```` + +### [2026-07-04T07:28:08Z] reviewer_security (CONSENSUS_CONFIRMED): Consensus confirmed by reviewer_security + +orchestrator-side confirm (#3064 event loop) + +````yaml +id: b4a722c6-4b70-45 +phase: implement +metadata: + slice_id: slice-3 +```` + +### [2026-07-04T07:28:08Z] reviewer_concurrency (CONSENSUS_CONFIRMED): Consensus confirmed by reviewer_concurrency + +orchestrator-side confirm (#3064 event loop) + +````yaml +id: 8f9c9139-0703-49 +phase: implement +metadata: + slice_id: slice-3 +```` + +### [2026-07-04T07:28:09Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: a9b2d7c4-38a5-47 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:28:09Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +one-shot event handler action=ack (slice=slice-3) + +````yaml +id: 6a4815f3-0fe2-4c +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:28:15Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: 848081e6-b3ef-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:28:16Z] reviewer_contract (HEARTBEAT): heartbeat: WORKING + +in-tool-loop liveness (slice=slice-3) + +````yaml +id: e82db1d1-fd8e-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:28:46Z] reviewer_code → documenter (CONSENSUS_ACK): ACK from reviewer_code for documenter + +Documenter's gateway/CLAUDE.md decomposition-seam subsection (commit 00d7c958b) is factually accurate — verified every material claim against the coder's landed code at 3a85636: (1) '50 @app.route decorators' matches exactly (grep -c on barrel = 50; reconciles with tester's 61 registered routes since the extra 11 come from contract_bp/phase_bp/artifact_bp blueprints, so 50 is the precise @app.route count); (2) symbol-placement table correct — spot-checked _health.py (get_proxy_ca_cert/health_check/config_reload/_reload_all_config), _worktree.py (map_container_path_to_worktree/worktree_create/delete/list/worktrees_prune), _server.py (main/_run_health_server) all defined where claimed; (3) _git_ops.py largest at 1357 lines — matches; (4) seam-mechanism description (barrel re-exports + __all__, _b() call-time resolution, _BarrelLogger proxy, module-singleton seam attrs) matches the implementation I reviewed; (5) R3 container note (Dockerfile COPY gateway/gateway/, python3 -m gateway launch, port 9848 unchanged) accurate. Pure doc addition, no code impact. No issues. + +````yaml +id: f8fa244d-44df-47 +phase: implement +metadata: + payload: + artifact_references: + - gateway/CLAUDE.md + reason: "Documenter's gateway/CLAUDE.md decomposition-seam subsection (commit\ + \ 00d7c958b) is factually accurate \u2014 verified every material claim against\ + \ the coder's landed code at 3a85636: (1) '50 @app.route decorators' matches\ + \ exactly (grep -c on barrel = 50; reconciles with tester's 61 registered routes\ + \ since the extra 11 come from contract_bp/phase_bp/artifact_bp blueprints,\ + \ so 50 is the precise @app.route count); (2) symbol-placement table correct\ + \ \u2014 spot-checked _health.py (get_proxy_ca_cert/health_check/config_reload/_reload_all_config),\ + \ _worktree.py (map_container_path_to_worktree/worktree_create/delete/list/worktrees_prune),\ + \ _server.py (main/_run_health_server) all defined where claimed; (3) _git_ops.py\ + \ largest at 1357 lines \u2014 matches; (4) seam-mechanism description (barrel\ + \ re-exports + __all__, _b() call-time resolution, _BarrelLogger proxy, module-singleton\ + \ seam attrs) matches the implementation I reviewed; (5) R3 container note (Dockerfile\ + \ COPY gateway/gateway/, python3 -m gateway launch, port 9848 unchanged) accurate.\ + \ Pure doc addition, no code impact. No issues." + ack_version: 1 + attestation: + files_reviewed: + - gateway/CLAUDE.md + issues_found: 0 + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:28:48Z] documenter (HEARTBEAT): heartbeat: PROPOSED + +Documenter PROPOSED v1 (commit 00d7c958b on coder tip 3a85636d6): gateway/CLAUDE.md gateway/gateway/ decomposition seam subsection (task-3-5), 15-row submodule table verified against on-disk layout, routes convention + barrel seam mechanism + R3 Dockerfile/launch note. Only gateway/CLAUDE.md changed (+25/-1), ratchet exit 0. Worktree remounted after ~2h infra outage; authored+proposed immediately. Awaiting reviewer_contract + reviewer_code review. + +````yaml +id: fdc7f237-0ab3-46 +phase: implement +metadata: + state: PROPOSED + slice_id: slice-3 +```` + +### [2026-07-04T07:28:50Z] reviewer_code (CONSENSUS_CONFIRMED): Consensus confirmed by reviewer_code + +orchestrator-side confirm (#3064 event loop) + +````yaml +id: 840899fb-6424-49 +phase: implement +metadata: + slice_id: slice-3 +```` + +### [2026-07-04T07:28:52Z] reviewer_code (HEARTBEAT): heartbeat: WORKING + +context-measure occ=113348 util=0.11 cache_hit=0.98 decision=below_threshold + +````yaml +id: 4274a5c4-0c53-41 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:29:02Z] documenter (HEARTBEAT): heartbeat: WORKING + +context-measure occ=231257 util=0.23 cache_hit=1.00 decision=below_threshold + +````yaml +id: 902eeac3-83ac-46 +phase: implement +metadata: + state: WORKING + slice_id: slice-3 +```` + +### [2026-07-04T07:29:07Z] reviewer_contract → documenter (CONSENSUS_ACK): ACK from reviewer_contract for documenter + +Contract review of documenter's gateway/CLAUDE.md seam-table proposal at commit 00d7c958b (task-3-5 doc portion: "gateway/CLAUDE.md carries the concrete submodule layout"). Verified the documented layout against the actual tree at the proposal SHA: (1) all 15 files match the table exactly — barrel __init__.py + __main__.py + 13 _ submodules (_helpers, _health, _git_ops, _git_execute, _gh_ops, _gh_execute, _jira, _jira_writes, _confluence, _worktree, _sessions, _proxy, _server); (2) __main__.py confirmed present at 00d7c958b, so the documented `python3 -m gateway` launch shim is accurate; (3) @app.route decorator count is exactly 50, matching the documented "all 50 @app.route decorators" (consistent with the tester's 61 registered url-map rules — one rule per method); (4) largest _git_ops.py 1,357 lines matches the coder's landed decomposition. The routes-convention, barrel re-export/__all__, _b()/_BarrelLogger/module-singleton seam mechanism, and R3 Dockerfile-COPY + launch notes all correctly describe the shipped seam. task-3-5 doc portion satisfied (allowlist-drop/ratchet portion is the coder's, verified separately). + +````yaml +id: f320d548-1ce6-41 +phase: implement +metadata: + payload: + artifact_references: + - gateway/CLAUDE.md + - gateway/gateway/__init__.py + - gateway/gateway/__main__.py + reason: "Contract review of documenter's gateway/CLAUDE.md seam-table proposal\ + \ at commit 00d7c958b (task-3-5 doc portion: \"gateway/CLAUDE.md carries the\ + \ concrete submodule layout\"). Verified the documented layout against the actual\ + \ tree at the proposal SHA: (1) all 15 files match the table exactly \u2014\ + \ barrel __init__.py + __main__.py + 13 _ submodules (_helpers, _health,\ + \ _git_ops, _git_execute, _gh_ops, _gh_execute, _jira, _jira_writes, _confluence,\ + \ _worktree, _sessions, _proxy, _server); (2) __main__.py confirmed present\ + \ at 00d7c958b, so the documented `python3 -m gateway` launch shim is accurate;\ + \ (3) @app.route decorator count is exactly 50, matching the documented \"all\ + \ 50 @app.route decorators\" (consistent with the tester's 61 registered url-map\ + \ rules \u2014 one rule per method); (4) largest _git_ops.py 1,357 lines matches\ + \ the coder's landed decomposition. The routes-convention, barrel re-export/__all__,\ + \ _b()/_BarrelLogger/module-singleton seam mechanism, and R3 Dockerfile-COPY\ + \ + launch notes all correctly describe the shipped seam. task-3-5 doc portion\ + \ satisfied (allowlist-drop/ratchet portion is the coder's, verified separately)." + ack_version: 1 + attestation: + tasks_verified: + - task-3-5 + verified_commit: 00d7c958b862efa0558fdfc57abbb1dc75edadd7 + layout_match: 15 files (barrel + __main__ + 13 _cluster) match table exactly + route_decorators: 50 + cross_check: 50 @app.route decorators consistent with tester's 61 registered + rules; __main__.py present + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:29:07Z] orchestrator → documenter (STATUS): Ready to confirm — all confirm preconditions satisfied + +Your proposal (version 1) is ready to confirm — all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm. + +````yaml +id: 7f3b41a9-f682-4d +phase: implement +metadata: + ready_to_confirm: true + version: 1 + slice_id: slice-3 +```` + +### [2026-07-04T07:29:11Z] documenter (CONSENSUS_CONFIRMED): Consensus confirmed by documenter + +orchestrator-side confirm (#3064 event loop) + +````yaml +id: fcb3b8e2-e46b-47 +phase: implement +metadata: + slice_id: slice-3 +```` + +### [2026-07-04T07:29:11Z] reviewer_contract (CONSENSUS_CONFIRMED): Consensus confirmed by reviewer_contract + +orchestrator-side confirm (#3064 event loop) + +````yaml +id: 1de0a1b5-8f18-43 +phase: implement +metadata: + slice_id: slice-3 +```` + +### [2026-07-04T07:29:11Z] reviewer_security (CONSENSUS_CONFIRMED): Consensus confirmed by reviewer_security + +orchestrator-side confirm (#3064 event loop) + +````yaml +id: 43cea8f8-42ec-45 +phase: implement +metadata: + slice_id: slice-3 +```` + +### [2026-07-04T07:29:11Z] reviewer_concurrency (CONSENSUS_CONFIRMED): Consensus confirmed by reviewer_concurrency + +orchestrator-side confirm (#3064 event loop) + +````yaml +id: 2c13b7f4-ddae-44 +phase: implement +metadata: + slice_id: slice-3 +```` diff --git a/gateway/CLAUDE.md b/gateway/CLAUDE.md index 5f51b0ef44..cd3615cfd2 100644 --- a/gateway/CLAUDE.md +++ b/gateway/CLAUDE.md @@ -75,4 +75,28 @@ Pure refactor: every symbol is AST-identical to the pre-split file — no behavi > Seam table documents the slice-12 target layout (per the architect slice goal); finalized per-submodule symbol placement tracks the coder's landed decomposition and is retagged to the shipped layout on the post-landing doc pass. -`git_client/` was the first `gateway/` decomposition; the remaining gateway slice (`gateway/` itself, slice 18) appends its own subsection below. +### `gateway/gateway/` — Flask app + REST route handlers ([#3312](https://github.com/jwbron/egg/issues/3312), slice 18) + +`gateway.py` (10,648 lines, 419 KB — the structural outlier, over both the line and byte caps) → `gateway/gateway/` (barrel + 14 submodules; largest `_git_ops.py`, 1,357 lines). This is the Flask app and the policy-enforced `git`/`gh`/Jira/Confluence/session/proxy REST surface. The split follows the **routes-handling convention**: all 50 `@app.route(...)` decorators stay on thin wrapper functions in the `__init__.py` barrel, and each wrapper delegates to an implementation function in a responsibility-grouped `_.py` submodule — so the URL→handler map and the `gateway.gateway` import path are unchanged. The barrel does explicit per-symbol re-exports and declares `__all__`, keeping the full public API (`app`, `main`, `GitHubClient`, `WorktreeManager`, the policy constants/enums, and every helper) as the stable surface, so `patch("gateway.gateway.")`, `patch.object(gateway, "")`, and `monkeypatch.setattr` targets across the ~35 referencing files resolve unchanged. Cross-submodule seams are resolved on the barrel at call time via a `_b()` accessor; a `_BarrelLogger` proxy forwards `gateway.logger`; and module-singleton seams (`subprocess.run` / `time.sleep` / `open`) stay barrel attributes so their patch targets survive the split. + +| Submodule | Responsibility | Key symbols | +|-----------|----------------|-------------| +| `__init__.py` (barrel) | Flask `app`, all 50 `@app.route` thin wrappers, `main` bootstrap; per-symbol re-exports + `__all__`; `_b()` call-time seam resolution, `_BarrelLogger`, module-singleton seam attrs | `app`, `main`, re-exports of every symbol below | +| `__main__.py` | `python3 -m gateway` entry-point shim (replaces `python3 gateway.py`) → calls barrel `main()` | `main` (imported) | +| `_helpers.py` | JSON response/error builders, audit logging, orchestrator/squid connectivity checks, barrel accessor + logger proxy | `make_response`, `make_error`, `make_success`, `make_worktree_not_found_error`, `audit_log`, `_check_orchestrator_connectivity`, `_check_squid_health`, `_b`, `_BarrelLogger` | +| `_health.py` | Health-check + config-reload endpoints, proxy CA-cert exposure | `health_check`, `config_reload`, `get_proxy_ca_cert`, `_reload_all_config` | +| `_git_ops.py` (largest, 1,357 lines) | Policy-enforced `git push` / `git fetch` (attribution, upstream config, detached-head hinting) | `git_push`, `git_fetch`, `_detached_head_hint` | +| `_git_execute.py` | Generic validated `git` command execution | `git_execute` | +| `_gh_ops.py` | `gh` PR lifecycle: create/comment/edit/close, open-PR lookup, merge-state/ready checks, label application | `gh_pr_create`, `gh_pr_comment`, `gh_pr_edit`, `gh_pr_close`, `gh_find_open_pr`, `gh_list_open_prs`, `gh_pr_merge_state`, `gh_pr_ready`, `_apply_pr_labels` | +| `_gh_execute.py` | Generic validated `gh` command execution | `gh_execute` | +| `_jira.py` | Jira read routes + orchestrator-authorized transition | `jira_ticket_get`, `jira_search`, `jira_ticket_comments`, `jira_ticket_remotelinks`, `jira_ticket_transition`, `jira_execute` | +| `_jira_writes.py` | Jira write routes + key/text/label validation | `jira_ticket_create`, `jira_ticket_edit`, `jira_ticket_comment_add`, `jira_issue_link_create`, `_validate_jira_write_keys` | +| `_confluence.py` | Read-only Confluence proxy: space-key resolution, limit clamping, page-id/space validation, upstream-error shaping | `_resolve_space_key_for_payload`, `_confluence_clamp_limit`, `_validate_confluence_page_id`, `_validate_confluence_space_key`, `_confluence_error_from_upstream` | +| `_worktree.py` | Worktree REST ops + container-path→worktree mapping, stale-pack/dir cleanup | `map_container_path_to_worktree`, `worktree_create`, `worktree_delete`, `worktree_list`, `worktrees_prune` | +| `_sessions.py` | Session lifecycle, heartbeats, phase updates, repo-visibility, session listing | `session_create`, `session_delete`, `session_get`, `session_heartbeat`, `session_update`, `session_update_phase`, `repos_visibility`, `sessions_list` | +| `_proxy.py` | Upstream LLM proxy: route-chain resolution, credential/attribution injection, hop preparation + streaming | `_prepare_hop`, `_inject_upstream_credentials`, `_inject_anthropic_credentials`, `_resolve_route_chain`, `_with_attribution_headers`, `_PreparedHop` | +| `_server.py` | Server bootstrap `main()` + background health-server thread | `main`, `_run_health_server` | + +Pure refactor: every implementation body is AST-identical to the pre-split file — no behavior change. **Container packaging + launch (R3):** `gateway/Dockerfile` gains `COPY gateway/gateway/ ./gateway/` (the non-recursive `COPY gateway/*.py ./` no longer matches the package dir), and because a package cannot be run as `python3 gateway.py`, the launch becomes `python3 -m gateway` (via `gateway/gateway/__main__.py` → barrel `main()`) in `gateway/entrypoint.sh`; the Flask server still binds port 9848 unchanged. + +`git_client/` was the first `gateway/` decomposition; `gateway/gateway/` (this slice) completes the gateway-package split. diff --git a/gateway/Dockerfile b/gateway/Dockerfile index de0d354abd..b0cf9a0e8f 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -73,6 +73,11 @@ COPY gateway/git_client/ ./git_client/ # caveat as git_client — copy it explicitly or `import worktree_manager` fails # at runtime with ModuleNotFoundError. COPY gateway/worktree_manager/ ./worktree_manager/ +# gateway.py became the gateway/ sub-package (#3312 slice-18); same +# non-recursive-glob caveat — copy it explicitly or `python3 -m gateway` +# (the entrypoint launch, replacing `python3 gateway.py`) fails at runtime +# with ModuleNotFoundError. +COPY gateway/gateway/ ./gateway/ # Copy shared modules (egg_logging for logging, egg_config for config parsing, egg_contracts for contract API, egg_restrictions for agent role enforcement, egg_health for health tracking, egg_git for cross-process git locking, egg_session_placeholder for /v1/messages token-keyed session lookup) COPY shared/egg_logging/ ./egg_logging/ diff --git a/gateway/entrypoint.sh b/gateway/entrypoint.sh index 792949d621..2298be44bb 100644 --- a/gateway/entrypoint.sh +++ b/gateway/entrypoint.sh @@ -337,12 +337,17 @@ if [ -n "${HOST_UID:-}" ] && [ -n "${HOST_GID:-}" ] && [ "$(id -u)" = "0" ]; the gosu "$HOST_UID:$HOST_GID" git config --global user.name "$GIT_NAME" gosu "$HOST_UID:$HOST_GID" git config --global user.email "$GIT_EMAIL" - exec gosu "$HOST_UID:$HOST_GID" python3 gateway.py --host 0.0.0.0 --port 9848 + # gateway.py became the gateway/ sub-package (#3312 slice-18); launch it as a + # module (`python3 -m gateway`, running gateway/__main__.py -> main()) instead + # of a script. WORKDIR /app is on sys.path so the flat sibling modules resolve. + exec gosu "$HOST_UID:$HOST_GID" python3 -m gateway --host 0.0.0.0 --port 9848 else # Configure global git identity for gateway operations (commits, etc.) echo "Configuring git identity for gateway: $GIT_NAME <$GIT_EMAIL>" git config --global user.name "$GIT_NAME" git config --global user.email "$GIT_EMAIL" - exec python3 gateway.py --host 0.0.0.0 --port 9848 + # gateway.py became the gateway/ sub-package (#3312 slice-18); launch as a + # module (see the gosu branch above for rationale). + exec python3 -m gateway --host 0.0.0.0 --port 9848 fi diff --git a/gateway/gateway.py b/gateway/gateway.py deleted file mode 100644 index 72227ced33..0000000000 --- a/gateway/gateway.py +++ /dev/null @@ -1,10648 +0,0 @@ -#!/usr/bin/env python3 -""" -Gateway Sidecar - REST API for policy-enforced git/gh operations. - -Provides a REST API that egg containers call to perform git push and gh operations. -The gateway holds GitHub credentials and enforces ownership policies. - -Security: - - Authentication via launcher secret (EGG_LAUNCHER_SECRET) and session tokens - - Listens on all interfaces (containers access via host.docker.internal) - -Endpoints: - POST /api/v1/git/push - Push to remote (policy: branch_ownership or trusted_user) - POST /api/v1/git/fetch - Fetch from remote (no policy - read operations allowed) - POST /api/v1/gh/pr/create - Create PR (policy: blocked in user mode) - POST /api/v1/gh/pr/comment - Comment on PR (policy: none - allowed on any PR) - POST /api/v1/gh/pr/edit - Edit PR (policy: pr_ownership) - POST /api/v1/gh/pr/close - Close PR (policy: pr_ownership) - POST /api/v1/gh/execute - Generic gh command (policy: filtered) - POST /api/v1/jira/ticket/get - Read Jira issue (policy: private-mode, project allowlist) - POST /api/v1/jira/search - JQL search (policy: private-mode, statically project-scoped) - POST /api/v1/jira/ticket/comments - Read Jira issue comments (policy: private-mode, project allowlist) - POST /api/v1/jira/execute - Generic read-only Jira REST call (policy: private-mode, allowlisted path) - POST /api/v1/jira/ticket/create - Create Jira issue (policy: private-mode, project allowlist; #1924) - POST /api/v1/jira/ticket/edit - Edit Jira issue (policy: private-mode, project allowlist; #1924) - POST /api/v1/jira/ticket/comment/add - Add Jira issue comment (policy: private-mode, project allowlist; #1924) - POST /api/v1/jira/issue-link/create - Link two Jira issues (policy: private-mode, both projects allowlisted; #1924) - POST /api/v1/confluence/page/get - Read Confluence page (policy: private-mode, space allowlist) - POST /api/v1/confluence/page/descendants - List page descendants (policy: private-mode, space allowlist) - POST /api/v1/confluence/page/footer-comments - Read page footer comments (policy: private-mode, space allowlist) - POST /api/v1/confluence/page/inline-comments - Read page inline comments (policy: private-mode, space allowlist) - POST /api/v1/confluence/space/list - List allowlisted spaces (policy: private-mode) - POST /api/v1/confluence/space/pages - List pages in a space (policy: private-mode, space allowlist) - POST /api/v1/confluence/search - CQL search (policy: private-mode, statically space-scoped) - POST /api/v1/confluence/execute - Generic read-only Confluence REST call (policy: private-mode, allowlisted path) - GET /api/v1/health - Health check (no auth required) - -Usage: - gateway.py [--host HOST] [--port PORT] [--debug] -""" - -import argparse -import functools -import json -import os -import re -import secrets -import signal -import socket -import subprocess -import sys -import threading -import time -import traceback -from collections.abc import Callable -from datetime import UTC, datetime -from pathlib import Path -from typing import Any, NamedTuple - -import httpx -from flask import Flask, Response, g, has_request_context, jsonify, request, stream_with_context -from waitress import serve - -# Add shared directory to path for egg_logging -# In container, egg_logging is at /app/egg_logging -# On host, it's at ../../shared/egg_logging -_shared_path = Path(__file__).parent.parent.parent / "shared" -if _shared_path.exists(): - sys.path.insert(0, str(_shared_path)) -from egg_health import HealthTracker -from egg_logging import get_logger -from egg_restrictions.hints import derive_hint as _derive_push_denied_hint -from egg_session_placeholder import from_placeholder as _session_token_from_placeholder - -# Module-level health tracker. Updated every time the /api/v1/health -# endpoint is evaluated so callers can distinguish "healthy since process -# start" from "just came up / recent flapping" (see issue #1855). -_health_tracker = HealthTracker() - -# Import gateway modules - try relative import first (module mode), -# fall back to absolute import (standalone script mode in container) -try: - from .agent_restrictions import ( - check_agent_gh_operation, - get_agent_pattern, # noqa: F401 — re-exported for test patching - ) - from .anthropic_credentials import ( - get_credentials_manager, - get_litellm_credentials_manager, - ) - from .confluence_client import ( - DEFAULT_LIMIT as CONFLUENCE_DEFAULT_LIMIT, - ) - from .confluence_client import ( - HARD_MAX_LIMIT as CONFLUENCE_HARD_MAX_LIMIT, - ) - from .confluence_client import ( - ConfluenceCredentialsUnavailable, - ConfluenceResponseTooLarge, - ConfluenceUpstreamError, - ConfluenceUpstreamForbidden, - get_confluence_client, - redact_response, - validate_confluence_api_path, - ) - from .confluence_credentials import reload_confluence_credentials - from .confluence_policy import ( - allowed_spaces as confluence_allowed_spaces, - ) - from .confluence_policy import ( - is_space_allowed as is_confluence_space_allowed, - ) - from .confluence_policy import ( - reload_confluence_policy, - ) - from .confluence_search import extract_search_spaces - from .git_client import ( - GIT_ALLOWED_COMMANDS, - cleanup_credential_helper, - create_credential_helper, - extract_reset_target_ref, - get_authenticated_remote_target, - get_changed_files_in_push, - get_token_for_repo, - git_cmd, - is_branch_switch, - is_branch_switching_operation, - is_repos_parent_directory, - resolve_remote_url, - validate_git_args, - validate_repo_path, - ) - from .github_client import ( - ALLOWED_GH_COMMANDS, - BLOCKED_GH_COMMANDS, - GH_COMMANDS_BLOCKED_IN_PRIVATE_MODE, - GitHubClient, - extract_comment_edit_info, - extract_issue_label_info, - extract_pr_review_info, - extract_pr_reviewer_info, - extract_repo_from_gh_command, - find_gh_command_index, - get_github_client, - is_gh_command_allowed, - parse_gh_api_args, - resolve_gh_api_template_variables, - validate_gh_api_path, - ) - from .jira_client import ( - JiraCredentialsUnavailable, - JiraUpstreamError, - get_jira_client, - validate_jira_api_path, - ) - from .jira_client import ( - validate_fields as validate_jira_fields, - ) - from .jira_credentials import reload_jira_credentials - from .jira_policy import ( - epic_link_field as jira_epic_link_field, - ) - from .jira_policy import ( - extract_project_key, - is_project_allowed, - reload_jira_policy, - ) - from .jira_policy import ( - link_type_allowed as jira_link_type_allowed, - ) - from .jira_search import extract_search_projects - from .mode_gate import require_private_mode - from .orchestrator_pipelines import ( - fetch_active_pipeline_ids, - wait_for_active_pipeline_ids, - ) - from .phase_filter import ( - OperationType, - PipelinePhase, - check_agent_restrictions, # noqa: F401 — re-exported for test patching - check_anchor_write_permission, - check_phase_file_restrictions, - filter_operation, - ) - from .policy import ( - extract_branch_from_refspec, - extract_repo_from_remote, - get_policy_engine, - reload_policy_caches, - ) - from .private_repo_policy import ( - check_private_repo_access, - ) - from .rate_limiter import ( - check_heartbeat_rate_limit, - record_failed_lookup, - ) - from .repo_parser import OWNER_REPO_PATTERN, parse_owner_repo - from .repo_visibility import get_repo_visibility - from .routing_policy import ( - RouteHop, - get_routing_policy_manager, - ) - from .session_manager import ( - get_session_manager, - validate_session_for_request, - ) - from .upstream_registry import ( - UnknownUpstreamError, - get_upstream_registry, - ) - from .worktree_manager import ( - REPOS_BASE_DIR, - WORKTREE_BASE_DIR, - WorktreeManager, - get_active_docker_containers, - startup_cleanup, - validate_branch_ref, - validate_identifier, - ) -except ImportError: - from agent_restrictions import ( # type: ignore[no-redef, import-untyped] - check_agent_gh_operation, - get_agent_pattern, # noqa: F401 — re-exported for test patching - ) - from anthropic_credentials import ( # type: ignore[no-redef] - get_credentials_manager, - get_litellm_credentials_manager, - ) - from git_client import ( # type: ignore[no-redef, import-untyped] - GIT_ALLOWED_COMMANDS, - cleanup_credential_helper, - create_credential_helper, - extract_reset_target_ref, - get_authenticated_remote_target, - get_changed_files_in_push, - get_token_for_repo, - git_cmd, - is_branch_switch, - is_branch_switching_operation, - is_repos_parent_directory, - resolve_remote_url, - validate_git_args, - validate_repo_path, - ) - from github_client import ( # type: ignore[no-redef, import-untyped] - ALLOWED_GH_COMMANDS, - BLOCKED_GH_COMMANDS, - GH_COMMANDS_BLOCKED_IN_PRIVATE_MODE, - GitHubClient, - extract_comment_edit_info, - extract_issue_label_info, - extract_pr_review_info, - extract_pr_reviewer_info, - extract_repo_from_gh_command, - find_gh_command_index, - get_github_client, - is_gh_command_allowed, - parse_gh_api_args, - resolve_gh_api_template_variables, - validate_gh_api_path, - ) - - # The Jira modules are new in issue #1556 and the flat-module test - # conftest does not yet preload them. Make the gateway directory - # discoverable before the fallback import so standalone / test loading - # still finds jira_client, jira_credentials, jira_policy, jira_search, - # and mode_gate by name. In production (package import), the relative - # ``from .jira_client import ...`` path above succeeds and this branch - # never runs. - _egg_gateway_dir = str(Path(__file__).parent) - if _egg_gateway_dir not in sys.path: - sys.path.insert(0, _egg_gateway_dir) - from confluence_client import ( # type: ignore[no-redef, import-untyped] - DEFAULT_LIMIT as CONFLUENCE_DEFAULT_LIMIT, - ) - from confluence_client import ( # type: ignore[no-redef] - HARD_MAX_LIMIT as CONFLUENCE_HARD_MAX_LIMIT, - ) - from confluence_client import ( # type: ignore[no-redef] - ConfluenceCredentialsUnavailable, - ConfluenceResponseTooLarge, - ConfluenceUpstreamError, - ConfluenceUpstreamForbidden, - get_confluence_client, - redact_response, - validate_confluence_api_path, - ) - from confluence_credentials import ( # type: ignore[no-redef, import-untyped] - reload_confluence_credentials, - ) - from confluence_policy import ( # type: ignore[no-redef, import-untyped] - allowed_spaces as confluence_allowed_spaces, - ) - from confluence_policy import ( # type: ignore[no-redef] - is_space_allowed as is_confluence_space_allowed, - ) - from confluence_policy import ( # type: ignore[no-redef] - reload_confluence_policy, - ) - from confluence_search import ( # type: ignore[no-redef, import-untyped] - extract_search_spaces, - ) - from jira_client import ( # type: ignore[no-redef, import-untyped] - JiraCredentialsUnavailable, - JiraUpstreamError, - get_jira_client, - validate_jira_api_path, - ) - from jira_client import ( # type: ignore[no-redef] - validate_fields as validate_jira_fields, - ) - from jira_credentials import ( # type: ignore[no-redef, import-untyped] - reload_jira_credentials, - ) - from jira_policy import ( # type: ignore[no-redef, import-untyped] - epic_link_field as jira_epic_link_field, - ) - from jira_policy import ( # type: ignore[no-redef] - extract_project_key, - is_project_allowed, - reload_jira_policy, - ) - from jira_policy import ( # type: ignore[no-redef] - link_type_allowed as jira_link_type_allowed, - ) - from jira_search import ( # type: ignore[no-redef, import-untyped] - extract_search_projects, - ) - from mode_gate import require_private_mode # type: ignore[no-redef, import-untyped] - from orchestrator_pipelines import ( # type: ignore[no-redef, import-untyped] - fetch_active_pipeline_ids, - wait_for_active_pipeline_ids, - ) - from phase_filter import ( # type: ignore[no-redef, import-untyped] - OperationType, - PipelinePhase, - check_agent_restrictions, # noqa: F401 — re-exported for test patching - check_anchor_write_permission, - check_phase_file_restrictions, - filter_operation, - ) - from policy import ( # type: ignore[no-redef, import-untyped] - extract_branch_from_refspec, - extract_repo_from_remote, - get_policy_engine, - reload_policy_caches, - ) - from private_repo_policy import ( # type: ignore[no-redef] - check_private_repo_access, - ) - from rate_limiter import ( # type: ignore[no-redef, import-untyped] - check_heartbeat_rate_limit, - record_failed_lookup, - ) - from repo_parser import ( # type: ignore[no-redef, import-untyped] - OWNER_REPO_PATTERN, - parse_owner_repo, - ) - from repo_visibility import get_repo_visibility # type: ignore[no-redef] - from routing_policy import ( # type: ignore[no-redef, import-untyped] - RouteHop, - get_routing_policy_manager, - ) - from session_manager import ( # type: ignore[no-redef, import-untyped] - get_session_manager, - validate_session_for_request, - ) - from upstream_registry import ( # type: ignore[no-redef, import-untyped] - UnknownUpstreamError, - get_upstream_registry, - ) - from worktree_manager import ( # type: ignore[no-redef, import-untyped] - REPOS_BASE_DIR, - WORKTREE_BASE_DIR, - WorktreeManager, - get_active_docker_containers, - startup_cleanup, - validate_branch_ref, - validate_identifier, - ) - -# Import repo_config for user mode support -# Path setup needed because config is in a sibling directory -_config_path = Path(__file__).parent.parent / "config" -if _config_path.exists() and str(_config_path) not in sys.path: - sys.path.insert(0, str(_config_path)) -from repo_config import get_auth_mode - -logger = get_logger("gateway") - - -try: - # Production / package mode. - from ._module_loader import load_sibling_gateway_module as _load_sibling_gateway_module -except ImportError: - # Standalone-script mode (the test conftest loads gateway.py as - # a flat top-level module, in which case the relative import - # above raises ImportError before sys.modules has been seeded). - from _module_loader import ( # type: ignore[no-redef, import-untyped] - load_sibling_gateway_module as _load_sibling_gateway_module, - ) - - -def _lookup_commit_observer_fn(name: str) -> Any: - """Return a callable from ``commit_observer`` without relative imports.""" - mod = _load_sibling_gateway_module("commit_observer") - if mod is None: - return None - return getattr(mod, name, None) - - -def _detached_head_hint( - operation: str, - exec_path: str, - repo_path: str, - container_id: str | None, -) -> str: - """Return a recovery hint string when a `commit` lands on detached HEAD. - - Used by the git-execute handler to surface the exact ``update-ref`` - invocation an agent needs to set its work branch to the new commit - (issue #2162). The empty string means "no hint" — caller appends as-is. - - The trigger is intentionally narrow: - - * Only ``operation == "commit"`` and only when the session has an - ``assigned_branch`` — we do not want to noise non-pipeline sessions. - * ``git symbolic-ref --quiet HEAD`` must return exactly 1 with empty - stdout AND empty stderr. Returncode 128 (corrupt repo, .git missing, - "fatal: ...") and any non-empty stderr are treated as ambiguous and - yield no hint — telling the agent to run ``update-ref`` against a - broken repository would be misleading. - """ - if operation != "commit": - return "" - session = getattr(g, "session", None) - assigned = getattr(session, "assigned_branch", None) if session else None - if not isinstance(assigned, str) or not assigned: - return "" - try: - head_check = subprocess.run( - git_cmd("symbolic-ref", "--quiet", "HEAD"), - cwd=exec_path, - capture_output=True, - text=True, - timeout=2, - check=False, - ) - except OSError, subprocess.TimeoutExpired: - return "" - # Tight check: returncode 1 with no stdout and no stderr is unambiguously - # detached HEAD. Anything else (corrupt repo, missing .git, EAGAIN) gets - # no hint. - if head_check.returncode != 1: - return "" - if head_check.stdout.strip(): - return "" - if head_check.stderr.strip(): - # Symbolic-ref returncode==1 with empty stdout but non-empty stderr is - # ambiguous (e.g. future git versions writing config-deprecation - # warnings). Log at debug so a missing hint is debuggable rather than - # silent, and bail out — telling the agent to run update-ref against - # an unclear HEAD state would be misleading. - logger.debug( - "detached_head_hint_suppressed_stderr", - repo_path=repo_path, - container_id=container_id, - assigned_branch=assigned, - stderr=head_check.stderr.strip()[:200], - ) - return "" - logger.info( - "detached_head_commit_hint", - repo_path=repo_path, - container_id=container_id, - assigned_branch=assigned, - ) - return ( - f"\n[gateway] HEAD is detached. Your commit is not on " - f"branch '{assigned}'. To set the branch to this commit, run:\n" - f" git update-ref refs/heads/{assigned} HEAD\n" - ) - - -app = Flask(__name__) - -# Register contract API blueprint -try: - from .contract_api import contract_bp - - app.register_blueprint(contract_bp) -except ImportError: - from contract_api import contract_bp # type: ignore[import-untyped, no-redef] - - app.register_blueprint(contract_bp) - -# Register phase API blueprint -try: - from .phase_api import phase_bp - - app.register_blueprint(phase_bp) -except ImportError: - from phase_api import phase_bp # type: ignore[import-untyped, no-redef] - - app.register_blueprint(phase_bp) - -# Register artifact API blueprint (#3077 slice-4: served reads of -# spec-registered coordination artifacts; modeled on contract_api). -try: - from .artifact_api import artifact_bp - - app.register_blueprint(artifact_bp) -except ImportError: - from artifact_api import artifact_bp # type: ignore[import-untyped, no-redef] - - app.register_blueprint(artifact_bp) - - -@app.errorhandler(Exception) -def handle_unhandled_exception(e: Exception) -> tuple[Response, int]: - """Return JSON for all unhandled exceptions instead of Flask's default HTML.""" - from werkzeug.exceptions import HTTPException - - if isinstance(e, HTTPException): - # Preserve HTTP status codes for werkzeug exceptions (400, 404, etc.) - return jsonify( - { - "success": False, - "message": e.description or str(e), - } - ), e.code or 500 - - logger.error( - "Unhandled exception in request handler", - error=str(e), - error_type=type(e).__name__, - path=request.path if request else "unknown", - traceback=traceback.format_exc(), - ) - return jsonify( - { - "success": False, - "message": "Internal server error", - } - ), 500 - - -# Configuration -DEFAULT_HOST = os.environ.get("GATEWAY_HOST", "0.0.0.0") # Listen on all interfaces by default -DEFAULT_PORT = 9848 -DEFAULT_THREADS = int(os.environ.get("GATEWAY_THREADS", "32")) -HEALTH_CHECK_PORT = int(os.environ.get("GATEWAY_HEALTH_PORT", "9851")) - -# Host home directory for path translation (explicit override). -# The gateway container uses /home/egg internally, but needs to return -# host paths to the orchestrator because those paths become the -# ``hostPath.path`` source of agent-pod mounts — if the gateway returns -# its in-pod path and the host layout doesn't match, kubelet -# ``DirectoryOrCreate``s an empty root-owned dir and the agent lands in -# an unwritable worktree (#1986). -# -# Normally we discover the host path directly from /proc/self/mountinfo -# (see ``translate_to_host_path``) so no env-var configuration is -# required. ``HOST_HOME`` is the escape hatch for environments where -# mountinfo doesn't reflect the real host layout (e.g. multi-partition -# setups, or an operator who wants to override the discovered value). -# Set ``EGG_DISABLE_MOUNTINFO=1`` to skip mountinfo entirely and force -# the ``HOST_HOME`` path — needed because real Linux containers always -# expose a rootfs ``/ → /`` entry that matches every path under longest- -# prefix lookup, so without the disable flag the env-var fallback is -# unreachable. -HOST_HOME = os.environ.get("HOST_HOME", "") -CONTAINER_HOME = "/home/egg" - - -def _mountinfo_disabled() -> bool: - return os.environ.get("EGG_DISABLE_MOUNTINFO", "").strip().lower() in { - "1", - "true", - "yes", - "on", - } - - -def _load_mount_mapping() -> list[tuple[str, str]]: - """Read /proc/self/mountinfo and return a list of (mount_point, host_root) tuples. - - For every mount visible to this process, ``mount_point`` is the path - in this process's mount namespace and ``host_root`` is the path the - kernel recorded as the mount root — for kubelet-managed ``hostPath`` - volumes that's the actual host path. The list includes *all* mount - types (not just bind mounts); longest-prefix matching in - ``translate_to_host_path`` ensures the most specific entry wins. - - Note: ``host_root`` (``fields[3]``, the mountinfo *root* field) is - the path relative to the filesystem's root. On single-partition - systems this equals the absolute host path; on multi-partition setups - it may be relative to the partition root. The ``HOST_HOME`` env var - is the escape hatch for those configurations. - - Note: mountinfo uses octal escapes for special characters in paths - (``\\040`` for space, ``\\011`` for tab, ``\\134`` for backslash). - We don't decode them — unlikely to matter for ``/home/...`` paths - but worth knowing if paths ever contain whitespace. - """ - entries: list[tuple[str, str]] = [] - if _mountinfo_disabled(): - return entries - try: - with open("/proc/self/mountinfo") as fh: - for line in fh: - # Format: mount_id parent_id major:minor root mount_point ... - fields = line.split() - if len(fields) < 5: - continue - entries.append((fields[4], fields[3])) - except OSError: - return [] - entries.sort(key=lambda p: len(p[0]), reverse=True) - return entries - - -_MOUNT_MAPPING: list[tuple[str, str]] = _load_mount_mapping() - - -def translate_to_host_path(container_path: str) -> str: - """ - Translate a container path to the corresponding host path. - - Tries in order: - 1. /proc/self/mountinfo — find the longest mount_point that is a - prefix of ``container_path`` and substitute with its host root. - This works for any hostPath volume without configuration. Real - Linux containers always include a rootfs ``/ → /`` entry, so - this strategy is reachable unless explicitly disabled. - 2. ``HOST_HOME`` env var — explicit override. To reach this branch - on Linux, set ``EGG_DISABLE_MOUNTINFO=1`` to skip the mountinfo - lookup (otherwise the ``/`` entry always matches first). - - Args: - container_path: Path inside the gateway container - - Returns: - The corresponding host path, or the original path if no - translation is possible. - """ - for mount_point, host_root in _MOUNT_MAPPING: - if container_path == mount_point or container_path.startswith(mount_point + "/"): - return host_root + container_path[len(mount_point) :] - - if HOST_HOME and container_path.startswith(CONTAINER_HOME): - return container_path.replace(CONTAINER_HOME, HOST_HOME, 1) - - return container_path - - -# Import session auth decorator from auth module to avoid circular imports -try: - from .auth import require_session_auth -except ImportError: - from auth import require_session_auth # type: ignore[no-redef, import-untyped] - - -# Launcher secret for session management and worktree operations -# This is used by the egg launcher to authenticate with the gateway -LAUNCHER_SECRET = os.environ.get("EGG_LAUNCHER_SECRET", "") -LAUNCHER_SECRET_FILE = Path("/secrets/launcher-secret") - - -class LauncherSecretNotConfiguredError(Exception): - """Raised when launcher secret is not configured.""" - - -def get_launcher_secret() -> str: - """Get the launcher secret from environment or file. - - The launcher secret is used to authenticate the egg launcher when - registering sessions. It should be generated by 'egg --setup' and - mounted at /secrets/launcher-secret. - - Raises: - LauncherSecretNotConfiguredError: If launcher secret is not found. - """ - global LAUNCHER_SECRET - - if LAUNCHER_SECRET: - return LAUNCHER_SECRET - - # Try to read from file (mounted from ~/.config/egg/launcher-secret) - if LAUNCHER_SECRET_FILE.exists(): - LAUNCHER_SECRET = LAUNCHER_SECRET_FILE.read_text().strip() - return LAUNCHER_SECRET - - raise LauncherSecretNotConfiguredError( - f"Launcher secret not found at {LAUNCHER_SECRET_FILE} or EGG_LAUNCHER_SECRET env var. " - "Run 'egg --setup' to generate it." - ) - - -def check_launcher_auth() -> tuple[bool, str]: - """ - Check if request has valid launcher authentication. - - Returns: - Tuple of (is_valid, error_message) - """ - secret = get_launcher_secret() - if not secret: - return False, "Launcher secret not configured" - - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): - return False, "Missing or invalid Authorization header" - - provided_token = auth_header[7:] # Remove "Bearer " prefix - - # Constant-time comparison to prevent timing attacks - if secrets.compare_digest(provided_token, secret): - return True, "" - - return False, "Invalid launcher authorization token" - - -def require_launcher_auth[F: Callable[..., Any]](f: F) -> F: - """Decorator to require launcher authentication for an endpoint.""" - - @functools.wraps(f) - def decorated(*args: Any, **kwargs: Any) -> Any: - is_valid, error = check_launcher_auth() - if not is_valid: - logger.warning( - "Launcher authentication failed", - endpoint=request.path, - error=error, - source_ip=request.remote_addr, - ) - return make_error(error, status_code=401) - return f(*args, **kwargs) - - return decorated # type: ignore[return-value] - - -def require_session_or_launcher_auth[F: Callable[..., Any]](f: F) -> F: - """Endpoint accepts either a session token or the launcher secret. - - When the Authorization bearer matches the launcher secret, the request - is treated as orchestrator-originated: ``g.session`` is left ``None`` - and ``g.auth_actor`` is set to ``"launcher"``. Otherwise the request - falls through to ``require_session_auth`` (sandbox/agent path), which - sets ``g.session`` and ``g.session_mode``/``g.session_phase``; this - wrapper then sets ``g.auth_actor = "session"``. - - The trust split is grounded in the launcher secret already used by - ``/api/v1/sessions/create`` and other privileged endpoints — only the - orchestrator holds it (mounted at ``/secrets/launcher-secret``), so a - request that authenticates with it is by definition not coming from - a sandboxed agent. - - Used by ``/api/v1/git/push`` so the orchestrator can run its own - failsafe pushes (contract init, state-sync, completion) without the - register-session/push/delete ceremony, and without tripping the - pipeline-push block (#2028) intended for agents. - """ - - # Build the session-auth fallback once at decoration time so we don't - # re-create the wrapper closure on every request. - @require_session_auth - def _session_path(*args: Any, **kwargs: Any) -> Any: - g.auth_actor = "session" - return f(*args, **kwargs) - - @functools.wraps(f) - def decorated(*args: Any, **kwargs: Any) -> Any: - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - try: - launcher_secret = get_launcher_secret() - except LauncherSecretNotConfiguredError: - launcher_secret = "" - if launcher_secret and secrets.compare_digest(auth_header[7:], launcher_secret): - g.session = None - g.session_mode = None - g.session_phase = None - g.auth_actor = "launcher" - return f(*args, **kwargs) - - # Fall through to session-token validation. The session decorator - # sets g.session/g.session_mode/g.session_phase on success and - # returns 401 on failure. - return _session_path(*args, **kwargs) - - return decorated # type: ignore[return-value] - - -def make_response( - success: bool, - message: str, - data: dict[str, Any] | None = None, - status_code: int = 200, -) -> tuple[Response, int]: - """Create a standardized JSON response.""" - response = {"success": success, "message": message} - if data: - response["data"] = data - return jsonify(response), status_code - - -def make_error( - message: str, status_code: int = 400, details: dict[str, Any] | None = None -) -> tuple[Response, int]: - """Create an error response.""" - return make_response(False, message, details, status_code) - - -def make_success(message: str, data: dict[str, Any] | None = None) -> tuple[Response, int]: - """Create a success response.""" - return make_response(True, message, data, 200) - - -def make_worktree_not_found_error(container_id: str) -> tuple[Response, int]: - """Return a 500 error when a container's worktree cannot be found. - - This prevents the silent fallback to the main repo that caused #1497: - agents could not see their own file changes because git ran against - the main repo instead of the agent's worktree. - """ - return make_error( - f"Worktree not found for container '{container_id}'. " - "The per-agent worktree may not have been created. " - "Git operations require a valid worktree.", - status_code=500, - ) - - -def audit_log( - event_type: str, - operation: str, - success: bool, - details: dict[str, Any] | None = None, -) -> None: - """Log an audit event in structured format.""" - log_data: dict[str, Any] = { - "timestamp": datetime.now(UTC).isoformat(), - "event_type": "gateway_operation", - "operation": operation, - "source_ip": request.remote_addr, - "success": success, - } - if details: - log_data.update(details) - - if success: - logger.info(f"Audit: {event_type}", **log_data) - else: - logger.warning(f"Audit: {event_type}", **log_data) - - -def _check_orchestrator_connectivity() -> dict[str, Any]: - """Check orchestrator connectivity if configured. - - Returns: - Dictionary with orchestrator status. Contains {"configured": False} - if orchestrator URL is not set, otherwise includes reachability info. - """ - orchestrator_url = os.environ.get("EGG_ORCHESTRATOR_URL") - if not orchestrator_url: - return {"configured": False} - - try: - # Use a short timeout for health checks - import urllib.request - - health_url = f"{orchestrator_url}/api/v1/health" - req = urllib.request.Request(health_url, method="GET") - with urllib.request.urlopen(req, timeout=2) as response: - data = json.loads(response.read().decode()) - return { - "configured": True, - "reachable": True, - "url": orchestrator_url, - "status": data.get("status", "unknown"), - } - except Exception as e: - return { - "configured": True, - "reachable": False, - "error": str(e), - } - - -def _check_squid_health() -> dict[str, Any]: - """Check if Squid proxy is running and listening on port 3129. - - Returns a dict with squid health info: - running: bool - True if the squid process is alive - listening: bool - True if port 3129 is accepting connections - """ - result: dict[str, Any] = {"running": False, "listening": False} - - # Check if squid process is running (not zombie) - try: - proc = subprocess.run( - ["pgrep", "-x", "squid"], - capture_output=True, - timeout=5, - ) - result["running"] = proc.returncode == 0 - except subprocess.TimeoutExpired, FileNotFoundError: - pass - - # Check if squid is actually accepting connections on port 3129. - # We use a direct TCP connect instead of 'squid -k check' because the - # latter re-parses squid.conf and fails when run as non-root (can't read - # the SSL private key), even though Squid itself is running fine. - try: - with socket.create_connection(("127.0.0.1", 3129), timeout=2): - result["listening"] = True - except OSError: - pass - - return result - - -@app.route("/api/v1/proxy/ca-cert", methods=["GET"]) -def get_proxy_ca_cert() -> tuple[Response, int] | Response: - """Serve the gateway proxy CA certificate (no auth required). - - The CA is public key material — every sandbox already receives it in - its trust store via the shared-certs volume in Compose mode. Under - k8s the agent Job ``command`` overrides the image ENTRYPOINT, so the - sandbox's ``setup_gateway_ca()`` never runs and no shared volume is - mounted; the one-shot event wrapper - (``orchestrator/consensus_wrapper.py``, #3459) fetches the current - CA from this endpoint per spawn and exports ``NODE_EXTRA_CA_CERTS``, - so agents validate TLS-bumped hosts (e.g. the GitHub Packages npm - read-through, #3456) without hand-wiring. Ad-hoc clients outside an - agent pod fetch the same way:: - - curl -sf "$GATEWAY_URL/api/v1/proxy/ca-cert" -o /tmp/gateway-ca.crt - NODE_EXTRA_CA_CERTS=/tmp/gateway-ca.crt pnpm install ... - - Serving it per-request also stays correct across gateway restarts, - which regenerate the CA (generate-ca-cert.sh). - """ - ca_path = Path("/etc/squid/certs/gateway-ca.crt") - if not ca_path.is_file(): - return jsonify({"error": "ca_cert_unavailable"}), 404 - return Response(ca_path.read_text(), mimetype="application/x-pem-file") - - -@app.route("/api/v1/health", methods=["GET"]) -def health_check() -> Response: - """Health check endpoint (no auth required).""" - github = get_github_client() - token_valid = github.is_token_valid() - - # Check launcher secret is configured - try: - get_launcher_secret() - launcher_secret_configured = True - except LauncherSecretNotConfiguredError: - launcher_secret_configured = False - - # Get session manager stats - session_manager = get_session_manager() - active_sessions = len(session_manager.list_sessions()) - - # Check orchestrator connectivity (if configured) - orchestrator_status = _check_orchestrator_connectivity() - - # Check Squid proxy health - squid_status = _check_squid_health() - - # Gateway always runs with locked Squid. - # Per-container mode is enforced at container start via network selection. - # - Private containers: isolated network + proxy (locked to api.anthropic.com) - # - Public containers: external network + direct internet (no proxy) - # - # Status is "degraded" if Squid is down - private containers will be unable - # to reach the internet. Previously invisible because health check only - # verified the Python gateway (port 9848), not Squid (port 3129). - # See: https://github.com/jwbron/egg/issues/1387 - is_healthy = token_valid and launcher_secret_configured and squid_status["listening"] - - # Record this observation so the snapshot can expose transitions (see #1855). - _health_tracker.record(is_healthy) - tracker_snapshot = _health_tracker.snapshot() - - response_data: dict[str, Any] = { - "status": "healthy" if is_healthy else "degraded", - "github_token_valid": token_valid, - "auth_configured": launcher_secret_configured, - "squid_proxy": squid_status, - "active_sessions": active_sessions, - "service": "gateway", - "client_ip": request.remote_addr, - "process_start_time": tracker_snapshot["process_start_time"], - "healthy_since": tracker_snapshot["healthy_since"], - "last_unhealthy_at": tracker_snapshot["last_unhealthy_at"], - "recent_transitions": tracker_snapshot["recent_transitions"], - } - - # Include orchestrator status if configured - if orchestrator_status.get("configured"): - response_data["orchestrator"] = orchestrator_status - - return jsonify(response_data) - - -def _reload_all_config() -> None: - """Reload all cached configuration from disk/environment. - - Called by the SIGHUP handler and the /api/v1/config/reload endpoint. - - Thread safety: all cached values are immutable types (frozenset, tuple, - None) and global variable assignment is atomic under CPython's GIL, so - concurrent readers see either the old or new value, never a torn state. - Avoid replacing any cache with a mutable type (e.g. dict) without adding - synchronisation. - """ - try: - from config.repo_config import reload_config as reload_repo_config - except ImportError: - try: - from repo_config import reload_config as reload_repo_config # type: ignore[no-redef] - except ImportError: - reload_repo_config = None # type: ignore[assignment] - - if reload_repo_config is not None: - try: - reload_repo_config() - finally: - reload_policy_caches() - logger.info("Configuration reloaded") - else: - reload_policy_caches() - logger.warning("Policy caches reloaded (repo_config unavailable)") - - # Jira credentials + project allowlist — both sit on disk next to the - # other gateway config, so a single ``POST /api/v1/config/reload`` should - # refresh them alongside the GitHub policy caches. Failing the Jira - # reload must not tank the endpoint (operators may be running without - # Jira configured), so we log and continue. - try: - reload_jira_credentials() - except Exception: # pragma: no cover — defensive - logger.exception("Jira credentials reload failed") - try: - reload_jira_policy() - except Exception: # pragma: no cover — defensive - logger.exception("Jira project allowlist reload failed") - # ``_reload_all_config`` is reachable from two call sites: (a) the - # ``POST /api/v1/config/reload`` endpoint, which runs inside a Flask - # request; and (b) the SIGHUP handler, which does NOT. ``audit_log`` - # dereferences ``request.remote_addr`` so calling it outside a request - # raises ``RuntimeError: Working outside of request context``. Gate - # the audit on ``has_request_context`` so HTTP reloads still audit and - # SIGHUP falls back to a bare logger line. - if has_request_context(): - audit_log( - "jira_config_reloaded", - "config_reload", - success=True, - details={"components": ["jira_credentials", "jira_policy"]}, - ) - else: - logger.info( - "Jira configuration reloaded", - components=["jira_credentials", "jira_policy"], - trigger="sighup", - ) - - # Confluence credentials + space allowlist — same disk-cache pattern as - # Jira. The Confluence allowlist lives under the ``confluence:`` section - # of context-filters.yaml; credentials share the secrets.env file. - try: - reload_confluence_credentials() - except Exception: # pragma: no cover — defensive - logger.exception("Confluence credentials reload failed") - try: - reload_confluence_policy() - except Exception: # pragma: no cover — defensive - logger.exception("Confluence space allowlist reload failed") - if has_request_context(): - audit_log( - "confluence_config_reloaded", - "config_reload", - success=True, - details={"components": ["confluence_credentials", "confluence_policy"]}, - ) - else: - logger.info( - "Confluence configuration reloaded", - components=["confluence_credentials", "confluence_policy"], - trigger="sighup", - ) - - -@app.route("/api/v1/config/reload", methods=["POST"]) -@require_launcher_auth -def config_reload() -> Response: - """Reload configuration from disk. - - Clears all in-memory config caches so the next access re-reads from - repositories.yaml and environment variables. Requires launcher auth. - """ - _reload_all_config() - return jsonify({"status": "ok", "message": "Configuration reloaded"}) - - -# Slice integration-branch shape for the synthetic-session exemption (#2368). -# Matches ``egg//(slice|phase)-`` where ```` is a single -# segment naming the parent pipeline branch (issue-driven, JIRA-driven, or -# qualifier-suffixed) — multi-segment bases are never produced by the -# orchestrator, so the second character class excludes ``/``. Only -# orchestrator-issued sessions can ever set ``synthetic=True`` (the launcher -# secret gates ``/api/v1/sessions/create``), so this exemption is not reachable -# from a sandboxed agent's session token. -_SLICE_INTEGRATION_BRANCH_RE = re.compile(r"^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\d+$") -# NOTE: ``_CONTEXT_BRANCH_RE`` (the synthetic-session exemption for -# ``egg//context`` from #2548) was removed in #2777 (cq-2 / cq-4). -# The dedicated context branch is gone; the context PR now opens on -# ``egg//work → main`` directly and that branch already lives on -# the pipeline-session push-allow list. - - -@app.route("/api/v1/git/push", methods=["POST"]) -@require_session_or_launcher_auth -def git_push() -> tuple[Response, int] | Response: - """ - Handle git push requests. - - Request body: - { - "repo_path": "/path/to/repo", - "remote": "origin", - "refspec": "branch-name", - "force": false, - "force_with_lease": false, # safer alternative to force - "commit_sha": "<40-hex>", # alternative to refspec; consensus pushes only - } - - ``force_with_lease`` (#2137 stacked-PR reconciler) is preferred over - ``force`` for non-fast-forward pushes. Both flags are mutually - exclusive — ``force_with_lease`` takes precedence if both are set. - - Policy: branch_ownership - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - repo_path = data.get("repo_path") - remote = data.get("remote", "origin") - refspec = data.get("refspec", "") - force = data.get("force", False) - force_with_lease = data.get("force_with_lease", False) - container_id = data.get("container_id") - commit_sha = data.get("commit_sha", "") - - if not repo_path: - return make_error("Missing repo_path") - - # Detached-HEAD-tolerant consensus push (#2200): when the agent's HEAD - # is detached (post-rebase or otherwise), the helper cannot read - # ``git branch --show-current`` and instead supplies ``commit_sha``. - # The gateway derives the refspec server-side from the session's - # assigned branch. This is strictly tighter than an agent-supplied - # refspec because the existing ``push_target_enforcement`` block - # below already requires ``branch == session.assigned_branch``. - if commit_sha and not refspec: - if not data.get("consensus_push"): - audit_log( - "push_blocked", - "git_push", - success=False, - details={ - "repo_path": repo_path, - "reason": "commit_sha push requires consensus_push=true", - }, - ) - return make_error( - "commit_sha push requires consensus_push=true", - status_code=400, - ) - # Require a full SHA (40 = SHA-1, 64 = SHA-256). Abbreviated SHAs - # (7-39 chars) can resolve ambiguously on the gateway side; the - # helper always emits the full output of ``git rev-parse HEAD`` so - # there is no legitimate caller of the shorter range. The explicit - # ``isinstance`` guard turns a non-string payload into a clean 400 - # rather than a 500 from ``re.fullmatch``. - if not isinstance(commit_sha, str) or not re.fullmatch(r"[0-9a-f]{40,64}", commit_sha): - audit_log( - "push_blocked", - "git_push", - success=False, - details={ - "repo_path": repo_path, - "reason": f"Invalid commit_sha {commit_sha!r}", - }, - ) - return make_error( - f"Invalid commit_sha {commit_sha!r}: must be 40-64 hex chars", - status_code=400, - ) - session = getattr(g, "session", None) - assigned = getattr(session, "assigned_branch", None) if session else None - if not isinstance(assigned, str) or not assigned: - audit_log( - "push_blocked", - "git_push", - success=False, - details={ - "repo_path": repo_path, - "reason": "commit_sha push requires a pipeline session with assigned_branch", - }, - ) - return make_error( - "commit_sha push requires a pipeline session with an assigned branch", - status_code=400, - ) - refspec = f"{commit_sha}:refs/heads/{assigned}" - # Distinct audit event so post-incident review can distinguish a - # gateway-constructed refspec (commit_sha path) from an - # agent-supplied refspec; both flow through the same downstream - # ``push_*`` audit events and would otherwise be indistinguishable. - audit_log( - "push_via_commit_sha", - "git_push", - success=True, - details={ - "repo_path": repo_path, - "commit_sha": commit_sha, - "assigned_branch": assigned, - "constructed_refspec": refspec, - }, - ) - - # Validate repo_path to prevent path traversal attacks - path_valid, path_error = validate_repo_path(repo_path) - if not path_valid: - audit_log( - "push_blocked", - "git_push", - success=False, - details={"repo_path": repo_path, "reason": path_error}, - ) - return make_error(path_error, status_code=403) - - # Map container path to worktree path if container_id is provided - exec_path = map_container_path_to_worktree(repo_path, container_id, "push") - if exec_path is None: - return make_worktree_not_found_error(container_id) - - # Get remote URL to determine repo - remote_url, url_error = resolve_remote_url(remote, exec_path) - if url_error: - return make_error(url_error) - - # Extract repo from URL - repo = extract_repo_from_remote(remote_url) - if not repo: - return make_error(f"Could not parse repository from URL: {remote_url}") - - # Extract branch from refspec - branch = extract_branch_from_refspec(refspec) - if not branch: - # Try to get current branch - try: - result = subprocess.run( - git_cmd("branch", "--show-current"), - cwd=exec_path, - capture_output=True, - text=True, - timeout=10, - check=False, - ) - branch = result.stdout.strip() - except Exception: - pass - - if not branch: - return make_error("Could not determine branch to push") - - # Determine auth mode for this repo - auth_mode = get_auth_mode(repo) - - # Check Private Repo Mode policy (if enabled) - # Get session mode from request context (set by @require_session_auth decorator) - session_mode = getattr(g, "session_mode", None) - session_phase = getattr(g, "session_phase", None) - - # Orchestrator-authenticated push (launcher secret). The orchestrator - # has a different trust boundary than sandboxed agents — its pushes are - # programmatic (contract init, state-sync, completion) and bypass the - # session-derived enforcement (pipeline-push block, push-target check, - # role/phase file restrictions) that exists to sandbox agent commits. - # session_mode comes from the request body since there is no session. - is_orchestrator_push = getattr(g, "auth_actor", None) == "launcher" - if is_orchestrator_push: - mode_in = data.get("mode") - if mode_in is not None and mode_in not in ("public", "private"): - audit_log( - "push_blocked", - "git_push", - success=False, - details={ - "repo_path": repo_path, - "reason": f"Invalid mode {mode_in!r} on launcher-auth push", - }, - ) - return make_error( - f"Invalid mode {mode_in!r}: must be 'public' or 'private'", - status_code=400, - ) - session_mode = mode_in or session_mode - audit_log( - "push_orchestrator_authenticated", - "git_push", - success=True, - details={ - "repo_path": repo_path, - "remote": remote, - "refspec": refspec, - "reason": "Push authenticated with launcher secret — orchestrator-trusted", - }, - ) - - # Infrastructure branch bypass: pushes to infrastructure branches always succeed - # regardless of session mode or phase (pipeline state can be written at any time). - from egg_config.constants import PIPELINE_STATE_BRANCH - - INFRASTRUCTURE_BRANCHES = {PIPELINE_STATE_BRANCH} - is_infrastructure_push = branch in INFRASTRUCTURE_BRANCHES - - # Slice integration-branch creation (#2368): the orchestrator pre-creates - # ``egg//(slice|phase)-N`` on origin from the parent branch via a - # synthetic, launcher-authenticated session before any agent runs. That - # push is orchestrator infrastructure — not an agent BRC propose — so it - # must bypass the pipeline-session push block introduced in #2028. The - # ``synthetic=True`` flag can only be set by the launcher (the - # ``/api/v1/sessions/create`` endpoint is gated by ``require_launcher_auth``), - # so a sandboxed agent's session token cannot reach this branch. - # - # The legacy ``egg//context`` context-branch exemption (#2548) was - # removed in #2777 (cq-2 / cq-4): the dedicated context branch is gone - # and the context PR now opens on ``egg//work → main`` directly, - # which is already covered by the pipeline-session push-allow list. - is_slice_integration_push = False - if not is_infrastructure_push and _SLICE_INTEGRATION_BRANCH_RE.match(branch): - # ``Session.synthetic`` is a ``bool`` (default ``False``); only an - # orchestrator-issued session can carry ``synthetic=True`` because - # ``/api/v1/sessions/create`` is gated on the launcher secret. Use - # an identity check rather than a truthiness test so a future - # surface that ever stores something other than ``True`` (and any - # MagicMock fake whose default attr is truthy) cannot accidentally - # opt into the exemption. - if hasattr(g, "session") and getattr(g.session, "synthetic", False) is True: - is_slice_integration_push = True - is_infrastructure_push = True - audit_log( - "push_slice_integration_exempt", - "git_push", - success=True, - details={ - "repo_path": repo_path, - "remote": remote, - "refspec": refspec, - "branch": branch, - "reason": ( - "Synthetic-session slice integration branch push — " - "orchestrator infrastructure (#2368)" - ), - }, - ) - - repo_info = parse_owner_repo(repo) - if repo_info: - # Infrastructure operations — always accessible regardless of - # session mode. This covers infrastructure branch pushes - # (pipeline state) and synthetic slice-integration pushes. - if is_infrastructure_push: - if is_slice_integration_push: - exempt_type = "slice_integration_branch" - else: - exempt_type = "infrastructure_branch" - # A successful slice-integration push intentionally emits BOTH - # ``push_slice_integration_exempt`` (above, the orchestrator- - # specific event) AND ``push_infrastructure_exempt`` with - # ``exempt_type="slice_integration_branch"`` (here, the generic - # exemption event). Operators grepping ``push_infrastructure_exempt`` - # for "infra pushes" should filter out the slice variant via - # ``exempt_type``; the dual emission is intentional so the - # orchestrator-specific path is also visible to operators - # filtering on the slice-integration event name (#2370 review). - audit_log( - "push_infrastructure_exempt", - "git_push", - success=True, - details={ - "repo": repo, - "branch": branch, - "reason": "Infrastructure operation exempt from private mode policy", - "exempt_type": exempt_type, - }, - ) - else: - priv_result = check_private_repo_access( - operation="push", - owner=repo_info.owner, - repo=repo_info.repo, - for_write=True, - session_mode=session_mode, - ) - if not priv_result.allowed: - audit_log( - "push_denied_private_mode", - "git_push", - success=False, - details={ - "repo": repo, - "branch": branch, - "reason": priv_result.reason, - "visibility": priv_result.visibility, - "auth_mode": auth_mode, - }, - ) - return make_error( - priv_result.reason, - status_code=403, - details=priv_result.to_dict(), - ) - - # SECURITY: Pipeline push enforcement. - # All SDLC producer phases (refine/plan/implement) are BRC phases, so every - # pipeline-session push must route through mcp__brc__propose (which sets the - # consensus_push marker). A direct git push from a pipeline session — whether - # bare, mis-targeted, or correctly-targeted — is rejected with a single - # unambiguous error pointing at the right tool, instead of the three-layer - # error cascade that previously sent agents refspec-hunting (#2028). - # Infrastructure pushes (pipeline-state branch, etc.) are exempt. - if not is_infrastructure_push: - # Killswitch: PIPELINE_PUSH_ENFORCEMENT=false (legacy alias: - # CONCURRENT_PUSH_ENFORCEMENT=false) disables the block. - enforcement_env = os.environ.get( - "PIPELINE_PUSH_ENFORCEMENT", - os.environ.get("CONCURRENT_PUSH_ENFORCEMENT", "true"), - ) - pipeline_push_enforcement = enforcement_env.lower() not in ("false", "0", "no") - if pipeline_push_enforcement: - session_pipeline_id = None - if hasattr(g, "session") and g.session: - session_pipeline_id = getattr(g.session, "pipeline_id", None) - if isinstance(session_pipeline_id, str) and session_pipeline_id: - if not data.get("consensus_push"): - audit_log( - "push_denied_pipeline_session", - "git_push", - success=False, - details={ - "repo": repo, - "branch": branch, - "pipeline_id": session_pipeline_id, - "reason": "Direct push blocked for pipeline session", - }, - ) - return make_error( - "Direct git push is blocked for pipeline sessions. " - "Publish your artifact via the mcp__brc__propose tool " - "(which pushes to origin and sends CONSENSUS_PROPOSE " - "in one step). Fallback CLI: " - "`egg-orch consensus propose --push`.", - status_code=403, - details={ - "pipeline_id": session_pipeline_id, - "requirement": "consensus_push", - "recommended_tool": "mcp__brc__propose", - }, - ) - - # Push-target enforcement: a consensus_push request must still target the - # session's assigned branch. Defense-in-depth against a malformed propose - # call (consensus_push=true but wrong refspec). Non-pipeline sessions - # (e.g. user-mode pushes) are not subject to this check. - # Killswitch: PUSH_TARGET_ENFORCEMENT=false. - push_target_enforcement = os.environ.get("PUSH_TARGET_ENFORCEMENT", "true").lower() not in ( - "false", - "0", - "no", - ) - if push_target_enforcement and hasattr(g, "session") and g.session: - session_pipeline_id = getattr(g.session, "pipeline_id", None) - session_assigned_branch = getattr(g.session, "assigned_branch", None) - if isinstance(session_pipeline_id, str) and isinstance(session_assigned_branch, str): - if branch != session_assigned_branch: - audit_log( - "push_denied_wrong_branch", - "git_push", - success=False, - details={ - "repo": repo, - "branch": branch, - "assigned_branch": session_assigned_branch, - "pipeline_id": session_pipeline_id, - }, - ) - return make_error( - f"Pipeline sessions must push to their assigned branch " - f"'{session_assigned_branch}'. Got '{branch}'. " - f"mcp__brc__propose handles branch targeting for you.", - status_code=403, - details={ - "assigned_branch": session_assigned_branch, - "attempted_branch": branch, - "pipeline_id": session_pipeline_id, - }, - ) - - # Check branch ownership policy (pass auth mode for relaxed policy in user mode) - policy = get_policy_engine() - policy_result = policy.check_branch_ownership(repo, branch, auth_mode=auth_mode) - - if not policy_result.allowed: - audit_log( - "push_denied", - "git_push", - success=False, - details={ - "repo": repo, - "branch": branch, - "reason": policy_result.reason, - "auth_mode": auth_mode, - }, - ) - return make_error( - f"Push denied: {policy_result.reason}", - status_code=403, - details=policy_result.details, - ) - - # SECURITY: Resolve the changed-file set + fail closed if we can't. - # The agent-role and phase-based restriction checks below both consume - # ``changed_files``; computing it once here keeps the security gates - # consistent and lets the fail-closed branch run even if neither - # session has a role (the phase check still runs in that case). - # - # Infrastructure pushes (pipeline-state and synthetic-session slice - # integration-branch creation pushes; see is_infrastructure_push above) - # are exempt for two distinct reasons: - # 1. ``egg/pipeline-state`` is an orphan/disjoint-history branch written - # by orchestrator infrastructure, not agent BRC pushes, so role-based - # file restrictions don't conceptually apply. - # 2. Synthetic-session slice integration-branch creation pushes (#2368) - # diff against `main` because the target ref doesn't exist yet, which - # would otherwise pull in every file modified on the parent branch's - # history (drafts, contracts, brc-history, ...) and falsely block a - # logical no-op branch-creation push (#2372). - # The downstream anchor/phase/agent-restriction checks already gate on - # `not is_infrastructure_push`; this gate makes the role check symmetric. - session_role = None - # Pipeline base branch (#3024): used as the preferred diff base for the - # new-branch fallback so a branch forked from a non-trunk base is not - # blamed for files it inherited unchanged from that base. - session_base_branch = None - changed_files = None # populated below; reused by attribution + phase checks - if hasattr(g, "session") and g.session: - session_role = getattr(g.session, "agent_role", None) - session_base_branch = getattr(g.session, "base_branch", None) - - if session_role and not is_infrastructure_push: - # Get the list of files being pushed for downstream attribution-aware - # role enforcement (the canonical agent-role check below) and the - # phase-restriction check further down. - changed_files, check_error = get_changed_files_in_push( - exec_path, remote, branch, base_branch=session_base_branch - ) - - # SECURITY: Fail closed - if we can't determine changed files, block the push. - # This prevents bypass via git diff manipulation (timeout, corrupt refs, etc.) - if check_error: - audit_log( - "push_denied_file_check_failed", - "git_push", - success=False, - details={ - "repo": repo, - "branch": branch, - "role": session_role, - "error": check_error, - }, - ) - return make_error( - f"Push denied: Could not verify file changes for security check: {check_error}", - status_code=500, - details={ - "role": session_role, - "error": check_error, - "hint": "This is a security precaution. Try again or contact support.", - }, - ) - - # Note: the legacy whole-push-diff role check that used to live here - # (``check_file_restrictions(session_role, changed_files)``) was - # removed in #2489. It treated every file in the diff range as the - # pushing role's responsibility, even files modified only by pulled - # commits authored by other roles, which trapped role-restricted - # producers whose branches inherited unrelated upstream commits - # (the role had no sanctioned recovery path). The attribution- - # aware block below partitions own-authored vs pulled files via the - # commit-authorship registry and is now the canonical agent-role - # restriction enforcer; it preserves fail-closed semantics when - # attribution is unavailable. - - # Agent-role file restrictions (#2039 restricted-path rejection). - # The gateway partitions the push range into own-authored vs - # pulled-from-other-role files via the commit-authorship registry, - # checks the pushing role's write permissions against only the - # own-authored set, and either pushes unchanged (all allowed) - # or rejects with 403 restricted_path_modified (any blocked). - # - # EGG_AGENT_RESTRICTIONS_ENFORCE=false short-circuits the filter - # (warn-only, same as the old 403 path). - auto_filter_response: dict[str, Any] | None = None - attributed_push: Any = None - if session_role and changed_files and not is_infrastructure_push: - enforce = os.environ.get("EGG_AGENT_RESTRICTIONS_ENFORCE", "true").lower() not in ( - "false", - "0", - "no", - ) - _ar_mod = sys.modules.get("agent_restrictions") or sys.modules.get( - "gateway.agent_restrictions" - ) - _partition_fn: Any = getattr(_ar_mod, "partition_files_by_role", None) if _ar_mod else None - if _partition_fn is None: - try: - from agent_restrictions import ( - partition_files_by_role as _imported_partition, - ) - - _partition_fn = _imported_partition - except ImportError: # pragma: no cover - from .agent_restrictions import ( - partition_files_by_role as _imported_partition, - ) - - _partition_fn = _imported_partition - - _gc_mod = sys.modules.get("git_client") or sys.modules.get("gateway.git_client") - _get_attributed_fn: Any = ( - getattr(_gc_mod, "get_attributed_changed_files_in_push", None) if _gc_mod else None - ) - if _get_attributed_fn is None: - try: - from git_client import ( - get_attributed_changed_files_in_push as _imported_attr, - ) - - _get_attributed_fn = _imported_attr - except ImportError: # pragma: no cover - from .git_client import ( - get_attributed_changed_files_in_push as _imported_attr, - ) - - _get_attributed_fn = _imported_attr - - # Resolve attribution for every commit in the push range. - try: - attributed_push = _get_attributed_fn( - exec_path, - remote, - branch, - session_role=session_role, - base_branch=session_base_branch, - ) - except Exception as exc: - logger.warning("attribution_lookup_exception", error=str(exc), exc_info=True) - # Fail-closed: an unexpected exception is treated as - # attribution-unavailable so the rewrite path never - # pushes unvetted files. - _apr_cls = getattr(_gc_mod, "AttributedPushRange", None) if _gc_mod else None - if _apr_cls is not None: - attributed_push = _apr_cls(error=f"Attribution lookup failed: {exc}") - else: - from types import SimpleNamespace - - attributed_push = SimpleNamespace( - error=f"Attribution lookup failed: {exc}", - commits=[], - files=[], - attribution={}, - ) - - # When the per-commit attribution can't be computed (e.g. the - # caller mocked only the legacy file-detection path, or git - # rev-list returned zero commits but there are staged-but-not- - # pushed changes we can't walk with commit-tree), we FAIL - # CLOSED. Treat every file in ``changed_files`` as own-authored - # and unregistered; if any file is blocked the push is rejected - # by the restricted-path arm below (#2039). - attribution_fallback = bool(attributed_push.error or not attributed_push.commits) - if attribution_fallback: - own_files: list[str] = list(dict.fromkeys(changed_files)) - pulled_files: list[str] = [] - unregistered_files: list[str] = list(own_files) - attributed_commits_list: list[str] = [] - else: - # Split files by author role (pushing role's own vs pulled). - own_files = [] - pulled_files = [] - unregistered_files = [] - for attr in attributed_push.files: - if attr.authored_by is None: - # Fail-closed: unregistered commits are treated as - # own-authored. - own_files.append(attr.path) - unregistered_files.append(attr.path) - elif attr.authored_by == session_role: - own_files.append(attr.path) - else: - pulled_files.append(attr.path) - own_files = list(dict.fromkeys(own_files)) - pulled_files = list(dict.fromkeys(pulled_files)) - attributed_commits_list = list(attributed_push.commits) - - # Build the pulled_commits list for the response + audit log. - pulled_commits_summary: list[dict[str, Any]] = [] - for sha in attributed_commits_list: - role_for_sha = attributed_push.attribution.get(sha) if attributed_push else None - if role_for_sha and role_for_sha != session_role: - pulled_commits_summary.append({"sha": sha, "author_role": role_for_sha}) - - allowed_own, blocked_own = _partition_fn(session_role, own_files, repo=repo) - - if unregistered_files and enforce: - audit_log( - "push_authorship_unregistered_fallback", - "git_push", - success=True, - details={ - "repo": repo, - "branch": branch, - "role": session_role, - "unregistered_files": unregistered_files, - "blocked_paths": blocked_own, - "pulled_commits": pulled_commits_summary, - }, - ) - - if blocked_own and enforce: - # #2039: reject any push whose diff modifies a path the - # pushing role cannot write. The previous behavior — silent - # tree rewrite (mixed) or silent ``nothing_to_push=true`` - # (all-blocked) — produced destructive deletions on the - # shared branch and gave the agent no actionable signal. - # Reject loudly with a structured 403 that points at the - # supported recovery pattern (#1998 conditional ACK with - # ``--pre-merge-condition``). - sorted_blocked = sorted(set(blocked_own)) - audit_log( - "push_denied_restricted_path_modified", - "git_push", - success=False, - details={ - "repo": repo, - "branch": branch, - "role": session_role, - "blocked_paths": sorted_blocked, - "pulled_commits": pulled_commits_summary, - "attribution_fallback": attribution_fallback, - }, - ) - recommended_action = ( - "Drop the edits to the listed paths and re-propose with " - "--pre-merge-condition flagging a manual change for the " - "human reviewer (see issue #1998 for the conditional-ACK " - "pattern)." - ) - details: dict[str, Any] = { - "error": "restricted_path_modified", - "role": session_role, - "blocked_paths": sorted_blocked, - "recommended_action": recommended_action, - "doc_ref": "#1998", - "pulled_commits": pulled_commits_summary, - "attribution_fallback": attribution_fallback, - } - # #2355 hint catalogue: surface category-specific guidance - # (e.g. "Use egg-contract CLI commands…" for contract paths, - # "Documentation changes belong to the documenter role." for - # docs/) alongside the generic conditional-ACK pointer. The - # legacy whole-push-diff check used to do this; restoring it - # here keeps the response shape consistent with the anchor- - # write 403 below. - hint = _derive_push_denied_hint(sorted_blocked) - if hint is not None: - details["hint"] = hint - return make_error( - ( - f"Push denied: role '{session_role}' cannot modify restricted " - f"paths: {', '.join(sorted_blocked)}. " - f"{recommended_action}" - ), - status_code=403, - details=details, - ) - elif blocked_own and not enforce: - # Warn-only mode: log but let the plain push proceed. - # Explicitly flag ``enforce=false`` so operators scanning - # audit logs during a kill-switch window can distinguish - # this from the enforced paths. - logger.warning( - "Agent-role file restriction would block push (warn-only)", - event_type="agent_role_restriction_warning", - repo=repo, - branch=branch, - role=session_role, - blocked_files=blocked_own, - enforce=False, - ) - # Observability parity (#1882 TASK-3-3): even the warn- - # only passthrough must surface pulled_commits and the - # filtered=false flag in the success response so - # downstream tooling sees a consistent schema. - auto_filter_response = { - "filtered": False, - "excluded_files": [], - "pushed_files": own_files + pulled_files, - "pulled_commits": pulled_commits_summary, - } - else: - # All own-files are allowed. No rewrite needed. We still - # stash the pulled_commits summary so the success path can - # surface it in the response for observability. - auto_filter_response = { - "filtered": False, - "excluded_files": [], - "pushed_files": own_files + pulled_files, - "pulled_commits": pulled_commits_summary, - } - - # SECURITY: Check anchor file write scoping. - # Agents can only write to their own anchor file (.egg-state/agent-anchors/.json). - # The agent_anchor_id is set via the AGENT_ANCHOR_ID env var in the container. - if changed_files and not is_infrastructure_push: - session_anchor_id = None - if hasattr(g, "session") and g.session: - session_anchor_id = getattr(g.session, "agent_anchor_id", None) - for changed_file in changed_files: - anchor_result = check_anchor_write_permission(changed_file, session_anchor_id) - if not anchor_result.allowed: - audit_log( - "push_denied_anchor_write", - "git_push", - success=False, - details={ - "repo": repo, - "branch": branch, - "agent_anchor_id": session_anchor_id, - "blocked_files": anchor_result.blocked_files, - "blocked_reason": anchor_result.blocked_reason, - }, - ) - anchor_details: dict[str, Any] = { - "agent_anchor_id": session_anchor_id, - "blocked_files": anchor_result.blocked_files, - "blocked_reason": anchor_result.blocked_reason, - } - # Anchor-write violations bypass the role-level partition (the - # coder blocklist exempts .egg-state/agent-anchors/), so they - # need their own derive_hint call to deliver the - # orchestrator-API guidance from BLOCKED_HINTS. See #2355. - anchor_hint = _derive_push_denied_hint(anchor_result.blocked_files) - if anchor_hint is not None: - anchor_details["hint"] = anchor_hint - return make_error( - f"Push denied: {anchor_result.message}", - status_code=403, - details=anchor_details, - ) - - # SECURITY: Check phase-based file restrictions for local mode sessions. - # This replaces the blanket local-mode push block with granular phase-based - # restrictions. Each phase has specific allowed/blocked file patterns: - # - refine/plan: Can only push .egg-state/ files (contracts, drafts, checkpoints) - # - implement: Can push code but not .egg-state/ (except checkpoints) - # - pr: Can push everything - if session_phase and not is_infrastructure_push: - # Get the list of files being pushed (reuse if already fetched for role check) - if changed_files is None: - changed_files, check_error = get_changed_files_in_push( - exec_path, remote, branch, base_branch=session_base_branch - ) - if check_error: - audit_log( - "push_denied_file_check_failed", - "git_push", - success=False, - details={ - "repo": repo, - "branch": branch, - "phase": session_phase, - "error": check_error, - }, - ) - return make_error( - f"Push denied: Could not verify file changes for phase check: {check_error}", - status_code=500, - details={ - "phase": session_phase, - "error": check_error, - "hint": "This is a security precaution. Try again or contact support.", - }, - ) - - # Check phase-based file restrictions - phase_result = check_phase_file_restrictions(session_phase, changed_files) - if not phase_result.allowed: - audit_log( - "push_denied_phase_restrictions", - "git_push", - success=False, - details={ - "repo": repo, - "branch": branch, - "phase": session_phase, - "blocked_files": phase_result.blocked_files, - "blocked_reason": phase_result.blocked_reason, - }, - ) - has_non_state_files = any( - not f.startswith(".egg-state/") for f in phase_result.blocked_files - ) - # Pipeline sessions get a pipeline-specific hint pointing to - # egg-orch; non-pipeline sessions see the original generic hint. - session_pipeline_id = None - if hasattr(g, "session") and g.session: - session_pipeline_id = getattr(g.session, "pipeline_id", None) - - if has_non_state_files and isinstance(session_pipeline_id, str): - hint = ( - "Push contains files from prior pipeline phases that this phase " - "cannot modify. This indicates the worktree was not properly synced. " - "Signal an error with `egg-orch signal error --error 'Push denied: " - "phase file restrictions'` and include this message. " - f"Blocked files: {phase_result.blocked_files}" - ) - elif has_non_state_files: - hint = ( - "Branch contains files outside .egg-state/ from a previous phase. " - "Create a clean branch from origin/main with only your state files." - ) - else: - hint = f"Phase '{session_phase}' has file restrictions. Check allowed patterns." - return make_error( - f"Push denied: {phase_result.message}", - status_code=403, - details={ - "phase": session_phase, - "blocked_files": phase_result.blocked_files, - "blocked_reason": phase_result.blocked_reason, - "hint": hint, - }, - ) - - # Get authentication token using shared helper - token_str, auth_mode, token_error = get_token_for_repo(repo) - if not token_str: - return make_error(token_error, status_code=503) - - # Build push command with safe.directory for worktree paths - # Convert SSH URLs to HTTPS since gateway uses token auth - push_target = get_authenticated_remote_target(remote, remote_url) - if push_target != remote: - logger.debug( - "Converting SSH URL to HTTPS for push", - original_url=remote_url, - https_url=push_target, - ) - # SECURITY: Belt-and-suspenders hook prevention. The primary protection is - # core.hooksPath=/dev/null in git_cmd() which disables ALL hooks globally. - # --no-verify is added as defense-in-depth for the pre-push hook. See issue #58. - push_args = ["push", "--no-verify"] - if force_with_lease: - # ``--force-with-lease`` rejects the push if the remote has moved - # since we last fetched it — preferred over ``--force`` for - # non-fast-forward pushes (e.g. the stacked-PR reconciler's - # rebase-then-push heal path, #2137). - push_args.append("--force-with-lease") - elif force: - push_args.append("--force") - # NOTE: The push uses the original refspec (not a SHA-based refspec) - # because it never calls ``update-ref`` pre-push, so the directory- - # style ref collision (sibling worktree refs like - # ``refs/heads//work``) does not apply here. See #1994. - push_args.extend([push_target, refspec] if refspec else [push_target]) - # Clear any http.extraheader from .git/config to ensure the gateway's - # credential helper (GIT_ASKPASS) is used. actions/checkout@v4 persists - # GITHUB_TOKEN as an extraheader by default, which takes precedence over - # GIT_ASKPASS and may lack permissions (e.g., workflows scope). - cmd = git_cmd("-c", "http.extraheader=", *push_args) - - # NOTE: Git author/committer info is set at COMMIT time, not push time. - # For user mode, the user must configure their local git: - # git config user.name "Your Name" - # git config user.email "your@email.com" - if auth_mode == "user": - logger.debug("User mode push", repo=repo) - - # Create credential helper and execute push - credential_helper_path = None - try: - credential_helper_path, env = create_credential_helper(token_str, os.environ.copy()) - - result = subprocess.run( - cmd, - cwd=exec_path, - capture_output=True, - text=True, - timeout=120, - env=env, - check=False, - ) - - if result.returncode == 0: - audit_log( - "push_success", - "git_push", - success=True, - details={ - "repo": repo, - "branch": branch, - "force": force, - "auth_mode": auth_mode, - }, - ) - - # Update session bookkeeping after a successful push so other - # request handlers can resolve the session's current worktree. - session = getattr(g, "session", None) - if session is not None: - session.last_repo_path = exec_path - session.last_branch = branch - - success_payload: dict[str, Any] = { - "repo": repo, - "branch": branch, - "stdout": result.stdout, - "stderr": result.stderr, - "auth_mode": auth_mode, - } - # Surface pulled_commits / filtered=False on plain pushes so - # agents get consistent response shape across paths (#1882). - if auto_filter_response is not None: - success_payload.setdefault("filtered", auto_filter_response.get("filtered", False)) - success_payload.setdefault("nothing_to_push", False) - success_payload.setdefault( - "excluded_files", auto_filter_response.get("excluded_files", []) - ) - success_payload.setdefault( - "pushed_files", auto_filter_response.get("pushed_files", []) - ) - success_payload.setdefault( - "pulled_commits", auto_filter_response.get("pulled_commits", []) - ) - return make_success( - "Push successful", - success_payload, - ) - else: - audit_log( - "push_failed", - "git_push", - success=False, - details={ - "repo": repo, - "branch": branch, - "returncode": result.returncode, - "auth_mode": auth_mode, - }, - ) - return make_error( - f"Push failed: {result.stderr}", - status_code=500, - details={"stdout": result.stdout, "stderr": result.stderr}, - ) - - except subprocess.TimeoutExpired: - return make_error("Push timed out", status_code=504) - except Exception as e: - return make_error(f"Push failed: {e}", status_code=500) - finally: - cleanup_credential_helper(credential_helper_path) - - -@app.route("/api/v1/git/execute", methods=["POST"]) -@require_session_auth -def git_execute() -> tuple[Response, int] | Response: - """ - Execute a git command in the gateway's worktree. - - This is the primary endpoint for all git operations in the gateway-managed - worktree architecture. The container has no direct git access (its .git is - shadowed by tmpfs), so all git commands route through this endpoint. - - Request body: - { - "repo_path": "/home/egg/repos/myrepo", - "operation": "status", - "args": ["--porcelain"], - "container_id": "egg-xxx" # For path mapping - } - - Supported operations: status, add, commit, log, diff, show, branch, - checkout, switch, reset, restore, stash, merge, rebase, cherry-pick, - tag, clean, config, rev-parse, remote, apply, format-patch - - Network operations (push, fetch, ls-remote) should use dedicated endpoints. - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - repo_path = data.get("repo_path") - operation = data.get("operation") - args = data.get("args", []) - container_id = data.get("container_id") - - if not repo_path: - return make_error("Missing repo_path") - if not operation: - return make_error("Missing operation") - - # Validate repo_path - path_valid, path_error = validate_repo_path(repo_path) - if not path_valid: - audit_log( - "git_execute_blocked", - operation, - success=False, - details={ - "repo_path": repo_path, - "git_args": args, - "container_id": container_id, - "reason": path_error, - }, - ) - return make_error(path_error, status_code=403) - - # Check if this is a "repos parent" directory (contains repos but isn't one) - # Git operations in these directories are expected to fail - this is commonly - # caused by tools like Claude Code running `git rev-parse` to detect if they're - # in a repo. Return a clear error without logging a warning (since this is - # expected behavior, not an error condition). - if is_repos_parent_directory(repo_path): - logger.debug( - "Git operation in repos parent directory", - operation=operation, - repo_path=repo_path, - container_id=container_id, - ) - return make_error( - f"Path '{repo_path}' is a directory containing repositories, not a git repository. " - "Run git commands from within a specific repository directory.", - status_code=400, - details={ - "hint": "This directory contains repositories but is not itself a git repository.", - "repo_path": repo_path, - }, - ) - - # Validate operation is in allowlist - if operation not in GIT_ALLOWED_COMMANDS: - audit_log( - "git_execute_blocked", - operation, - success=False, - details={ - "repo_path": repo_path, - "git_args": args, - "container_id": container_id, - "reason": "Operation not allowed", - }, - ) - return make_error( - f"Operation '{operation}' not allowed. " - f"Allowed: {', '.join(sorted(GIT_ALLOWED_COMMANDS.keys()))}", - status_code=403, - ) - - # Network operations should use dedicated endpoints - if operation in ("push", "fetch", "ls-remote"): - return make_error( - f"Use dedicated endpoint for {operation}: /api/v1/git/{operation}", - status_code=400, - ) - - # Validate args against allowlist - args_valid, args_error, validated_args = validate_git_args(operation, args) - if not args_valid: - audit_log( - "git_execute_blocked", - operation, - success=False, - details={ - "repo_path": repo_path, - "git_args": args, - "container_id": container_id, - "reason": args_error, - }, - ) - return make_error(args_error, status_code=400) - - # SECURITY: Scope `git update-ref` to the agent's own assigned branch. - # update-ref is the supported recovery primitive when an agent ends up on - # detached HEAD with a useful commit (see issue #2162). To keep the blast - # radius tight, the gateway rejects any update-ref that is not of the form - # `update-ref []` and force-prepends - # `--no-deref` below so symref-following semantics never apply. - if operation == "update-ref": - session = getattr(g, "session", None) - assigned = getattr(session, "assigned_branch", None) if session else None - positional = [a for a in validated_args if not a.startswith("-")] - denial_reason: str | None = None - if not isinstance(assigned, str) or not assigned: - denial_reason = ( - "git update-ref is only allowed in pipeline sessions with an assigned branch." - ) - elif len(positional) < 2 or len(positional) > 3: - denial_reason = ( - "git update-ref must be of the form `git update-ref []`." - ) - else: - expected_ref = f"refs/heads/{assigned}" - if positional[0] != expected_ref: - denial_reason = ( - f"git update-ref target '{positional[0]}' is not allowed. " - f"Only '{expected_ref}' (your assigned branch) may be updated. " - f"If you are trying to manually retarget your branch to drop " - f"pulled upstream commits and recover from a " - f"'restricted_path_modified' push 403, that is no longer " - f"necessary (#2489) — pulled commits authored by other roles " - f"are exempt from your role allowlist; retry the push as-is." - ) - if denial_reason is not None: - audit_log( - "git_execute_blocked", - operation, - success=False, - details={ - "repo_path": repo_path, - "git_args": validated_args, - "container_id": container_id, - "assigned_branch": assigned, - "reason": denial_reason, - }, - ) - return make_error(denial_reason, status_code=403) - - # SECURITY: Scope `git symbolic-ref HEAD ` to the agent's own - # assigned or local per-role branch. symbolic-ref is the canonical - # reattach primitive when a worktree ends up on detached HEAD (e.g. - # post-rebase, see issue #2200). Restricted to the two-positional - # form `symbolic-ref HEAD ` — read forms (one-arg) and the - # delete form (`-d`) are rejected because they do not participate - # in the recovery flow. - if operation == "symbolic-ref": - session = getattr(g, "session", None) - assigned = getattr(session, "assigned_branch", None) if session else None - positional = [a for a in validated_args if not a.startswith("-")] - denial_reason = None - if not isinstance(assigned, str) or not assigned: - denial_reason = ( - "git symbolic-ref is only allowed in pipeline sessions with an assigned branch." - ) - elif len(positional) != 2: - denial_reason = "git symbolic-ref must be of the form `git symbolic-ref HEAD `." - elif positional[0] != "HEAD": - denial_reason = ( - f"git symbolic-ref source '{positional[0]}' is not allowed. " - f"Only HEAD may be retargeted." - ) - else: - allowed_refs = {f"refs/heads/{assigned}"} - # Defense in depth: scope the per-role local work branch from - # ``session.container_id`` (canonical, set by the orchestrator at - # session registration), not ``data.get("container_id")`` which - # is agent-supplied. Mirrors the ``update-ref`` guard above which - # also ignores the request-body container_id. - session_container_id = getattr(session, "container_id", None) - if isinstance(session_container_id, str) and session_container_id: - # Per-role local work branch (`egg/{container_id}/work`) - # — see worktree_manager._create_or_reuse_worktree. - allowed_refs.add(f"refs/heads/egg/{session_container_id}/work") - if positional[1] not in allowed_refs: - denial_reason = ( - f"git symbolic-ref target '{positional[1]}' is not allowed. " - f"Allowed targets: {sorted(allowed_refs)}." - ) - if denial_reason is not None: - audit_log( - "git_execute_blocked", - operation, - success=False, - details={ - "repo_path": repo_path, - "git_args": validated_args, - "container_id": container_id, - "assigned_branch": assigned, - "reason": denial_reason, - }, - ) - return make_error(denial_reason, status_code=403) - - # SECURITY: Block agent-initiated ``git rebase`` against the base - # branch from pipeline sessions (#2224, follow-up to #2222). The - # pipeline branch is rebased onto the base branch only via the - # orchestrator's controlled rebase in - # ``orchestrator/routes/pipelines.py::_rebase_pipeline_branch_onto_base`` - # — which itself uses the *bare* form ``git rebase origin/`` - # but is safe because steps 1–5 of the helper enforce ancestry - # preconditions and reset HEAD to the pipeline-branch tip *before* - # the rebase replays. Crucially, that helper runs as a subprocess - # on the orchestrator-side worktree and does *not* route through - # this endpoint, so this guard does not interfere with it. An - # agent reaching for ``git rebase origin/main`` (intentionally or - # via a "resolve conflicts" intuition) reproduces the contamination - # shape from #2222 even with the orchestrator-side fixes in place. - # - # The ``--onto X UP `` form is allowed when ``X`` (the - # *new* base) is *not* a protected ref — that shape is used by the - # stacked-PR healer in - # ``orchestrator/gateway_client.py::rebase_onto``, which always - # passes a slice/issue branch as ``new_base`` (never ``origin/main``; - # see ``stacked_pr_reconciler._resolve_extant_new_base``). Calls - # with ``--onto origin/main …`` are *blocked*: when ``X == UP == - # origin/main`` the operation reduces to bare ``git rebase - # origin/main`` and reproduces the contamination shape (the value - # of ``UP`` is irrelevant — the new HEAD is whatever ``X`` - # resolves to, with the upstream-to-HEAD commits replayed on top). - if operation == "rebase": - session = getattr(g, "session", None) - assigned = getattr(session, "assigned_branch", None) if session else None - if isinstance(assigned, str) and assigned: - # ``protected_refs`` lists every form an agent (or an - # innocent rename) could use to name the base branch. We - # normalise inputs by stripping ``refs/remotes/`` and - # ``refs/heads/`` prefixes before comparing so canonical - # full ref names hit the same guard. Pipelines whose base - # is not ``main`` are not currently in production - # (orchestrator's ``base_branch`` defaults to ``main``); if - # non-main bases ship, derive this set from the session's - # recorded base branch instead of hardcoding it. - protected_refs = { - "origin/main", - "main", - "origin/HEAD", - "FETCH_HEAD", - } - - def _normalise_ref(value: str) -> str: - # Strip ``refs/remotes/`` (canonical full remote-tracking - # ref) and ``refs/heads/`` (canonical local-branch ref) - # so e.g. ``refs/remotes/origin/main`` matches - # ``origin/main`` in ``protected_refs``. Other shapes - # (SHAs, ``origin/main~1``, ``origin/main^``) are caught - # by exact-match below or fall through — they are - # acknowledged in the docstring as residual gaps. - if value.startswith("refs/remotes/"): - return value[len("refs/remotes/") :] - if value.startswith("refs/heads/"): - return value[len("refs/heads/") :] - return value - - offender: str | None = None - - # Branch 1: ``--onto `` is present. Reject when - # the *new base* (the value of ``--onto``) is a protected - # ref, regardless of what the upstream positional is. This - # closes the ``--onto origin/main origin/main`` bypass: - # ``git rebase --onto X UP`` rebases HEAD onto X using UP as - # the upstream, so when X is the base branch the operation - # produces the same contamination shape as bare ``git - # rebase origin/main``. - # - # Collect *every* ``--onto`` occurrence rather than the - # first — git's ``OPT_STRING`` semantics make duplicate - # ``--onto`` flags overwrite, so the *last* value wins, and - # an adversarial ``--onto safe --onto origin/main`` would - # otherwise slip past a first-match check. Reject when any - # of the supplied values is a protected ref. Empty values - # (``--onto=`` with nothing after) are treated as "not - # provided" so the bare-form upstream check below still - # runs against the positional args. - onto_values: list[str] = [] - j = 0 - while j < len(validated_args): - arg = validated_args[j] - if arg.startswith("--onto="): - value = arg.split("=", 1)[1] - if value: - onto_values.append(value) - elif arg == "--onto" and j + 1 < len(validated_args): - value = validated_args[j + 1] - if value: - onto_values.append(value) - j += 1 - j += 1 - - if onto_values: - offender = next( - (v for v in onto_values if _normalise_ref(v) in protected_refs), - None, - ) - else: - # Branch 2: bare ``git rebase []`` - # form — first positional is the upstream. Reject when - # the upstream is a protected ref. - positional = [a for a in validated_args if not a.startswith("-")] - offender = next( - (p for p in positional if _normalise_ref(p) in protected_refs), - None, - ) - - if offender is not None: - denial_reason = ( - f"git rebase against '{offender}' is not allowed in " - f"pipeline sessions. The pipeline branch is rebased " - f"onto the base branch only via the orchestrator's " - f"controlled rebase (`_rebase_pipeline_branch_onto_base`), " - f"which runs as a subprocess that does not route through " - f"this endpoint; an agent-initiated `git rebase " - f"origin/main` (or `--onto origin/main …`) reproduces " - f"the contamination shape from #2222. If you need to " - f"bring in new commits from the base, ask the operator " - f"to resume the pipeline so the orchestrator-side " - f"rebase runs. If you were trying to drop pulled upstream " - f"commits to recover from a 'restricted_path_modified' " - f"push 403, that is no longer necessary (#2489) — pulled " - f"commits authored by other roles are exempt from your " - f"role allowlist; retry the push as-is." - ) - audit_log( - "git_execute_blocked", - operation, - success=False, - details={ - "repo_path": repo_path, - "git_args": validated_args, - "container_id": container_id, - "assigned_branch": assigned, - "reason": denial_reason, - }, - ) - return make_error(denial_reason, status_code=403) - - # SECURITY: Block branch-switching for pipeline sessions. - # Pipeline containers are locked to their worktree branch to prevent - # cross-contamination between pipeline tasks. - if is_branch_switch(operation, validated_args): - session = getattr(g, "session", None) - assigned = getattr(session, "assigned_branch", None) if session else None - if isinstance(assigned, str) and assigned: - audit_log( - "git_execute_blocked", - operation, - success=False, - details={ - "repo_path": repo_path, - "git_args": validated_args, - "container_id": container_id, - "assigned_branch": assigned, - "reason": "Branch switching blocked in pipeline session", - }, - ) - return make_error( - f"Branch switching is not allowed in pipeline sessions. " - f"You are locked to branch '{assigned}'. " - f"Use 'git checkout [] -- ' to restore files instead " - f"(e.g. 'git checkout HEAD -- ' or 'git checkout -- '). " - f"If you are recovering from a 'restricted_path_modified' push 403, " - f"note that pulled commits authored by other roles are exempt from " - f"your role allowlist (#2489) — only your own commits' paths trigger " - f"the rejection, so retry the push first; if it still rejects, drop " - f"the disallowed paths from your own commits and re-propose with " - f"--pre-merge-condition (#1998 conditional ACK).", - status_code=403, - ) - - # Map container path to worktree path if container_id is provided - exec_path = map_container_path_to_worktree(repo_path, container_id, operation) - if exec_path is None: - return make_worktree_not_found_error(container_id) - is_worktree = exec_path != repo_path - - # SECURITY: Block off-lineage `git reset` in pipeline sessions. - # `git reset ` (any mode) moves HEAD; if is not an ancestor of - # HEAD on the assigned branch, the agent's commits are silently dropped - # from the working tree — the same effect as a branch switch. The - # checkout/switch lock at :1924 does not catch this (see issue #2089). - if operation == "reset": - session = getattr(g, "session", None) - assigned = getattr(session, "assigned_branch", None) if session else None - if isinstance(assigned, str) and assigned: - target_ref = extract_reset_target_ref(validated_args) - if target_ref is not None: - ancestor_stderr: str | None = None - try: - ancestor_check = subprocess.run( - git_cmd("merge-base", "--is-ancestor", target_ref, "HEAD"), - cwd=exec_path, - capture_output=True, - text=True, - timeout=10, - check=False, - ) - is_ancestor = ancestor_check.returncode == 0 - if not is_ancestor and ancestor_check.stderr: - ancestor_stderr = ancestor_check.stderr.strip() or None - except (OSError, subprocess.TimeoutExpired) as exc: - # Fail closed — if we cannot verify safety, treat as off-lineage. - is_ancestor = False - ancestor_stderr = str(exc) - if not is_ancestor: - audit_details = { - "repo_path": repo_path, - "git_args": validated_args, - "container_id": container_id, - "assigned_branch": assigned, - "target_ref": target_ref, - "reason": "Off-lineage reset blocked in pipeline session", - } - if ancestor_stderr: - audit_details["merge_base_stderr"] = ancestor_stderr - audit_log( - "git_execute_blocked", - operation, - success=False, - details=audit_details, - ) - return make_error( - f"Off-lineage 'git reset' is not allowed in pipeline sessions. " - f"Target ref '{target_ref}' is not an ancestor of HEAD on your " - f"assigned branch '{assigned}'. To incorporate new commits from the " - f"remote, use 'git rebase origin/{assigned}' instead. " - f"If you are trying to drop pulled upstream commits to recover " - f"from a 'restricted_path_modified' push 403, that is no longer " - f"necessary (#2489) — pulled commits authored by other roles are " - f"exempt from your role allowlist; retry the push as-is.", - status_code=403, - ) - - # SECURITY: Enforce branch isolation in pipeline worktree sessions. - # Pipeline agents in worktrees must stay on their assigned branch. - # Interactive sessions are unrestricted even if they use worktrees. - # We detect pipeline sessions by the presence of pipeline_id on the - # session, rather than checking session_mode. - # See issue #773. - session = getattr(g, "session", None) - is_pipeline = session is not None and getattr(session, "pipeline_id", None) is not None - if is_pipeline and is_worktree and is_branch_switching_operation(operation, validated_args): - assert session is not None # guaranteed by is_pipeline check above - audit_log( - "git_execute_blocked", - operation, - success=False, - details={ - "repo_path": repo_path, - "git_args": args, - "container_id": container_id, - "pipeline_id": session.pipeline_id, - "session_mode": getattr(g, "session_mode", None), - "reason": "Branch switching blocked in pipeline worktree session", - }, - ) - return make_error( - "Branch switching is not allowed in pipeline worktree sessions. " - "You are locked to your assigned branch. " - "Use 'git restore' for file operations instead of 'git checkout'. " - "If you are recovering from a 'restricted_path_modified' push 403, " - "note that pulled commits authored by other roles are exempt from " - "your role allowlist (#2489) — retry the push as-is; if it still " - "rejects, drop the disallowed paths from your own commits and " - "re-propose with --pre-merge-condition (#1998 conditional ACK).", - status_code=403, - ) - - # SECURITY: Validate staged files at commit time for pipeline sessions. - # This is an early-catch complement to push-time validation — prevents the - # agent from building up invalid commits that would only be rejected at push. - if operation == "commit": - session = getattr(g, "session", None) - session_phase = getattr(g, "session_phase", None) if session else None - if session_phase: - import subprocess as _sp - - try: - staged_result = _sp.run( - git_cmd("diff", "--cached", "--name-only"), - cwd=exec_path, - capture_output=True, - text=True, - timeout=10, - check=False, - ) - if staged_result.returncode == 0: - staged_files = [ - f.strip() for f in staged_result.stdout.strip().split("\n") if f.strip() - ] - if staged_files: - phase_result = check_phase_file_restrictions(session_phase, staged_files) - if not phase_result.allowed: - audit_log( - "git_execute_blocked", - operation, - success=False, - details={ - "repo_path": repo_path, - "git_args": validated_args, - "container_id": container_id, - "phase": session_phase, - "blocked_files": phase_result.blocked_files, - "reason": "Staged files violate phase restrictions", - }, - ) - return make_error( - f"Commit blocked: {phase_result.message}. " - f"Unstage the blocked files with 'git reset HEAD '.", - status_code=403, - ) - except Exception: - # Fail open for commit-time check — push-time check is the - # authoritative gate and will catch any violations. - logger.warning( - "Staged-file check skipped due to error", - operation=operation, - container_id=container_id, - ) - - # SECURITY: Belt-and-suspenders hook prevention for operations that support it. - # The primary protection is core.hooksPath=/dev/null in git_cmd() which disables - # ALL hooks globally. However, we also add --no-verify for operations that - # support it as defense-in-depth. See issue #58. - # - # Operations that support --no-verify: - # - commit: pre-commit, prepare-commit-msg, commit-msg, post-commit - # - merge: pre-merge-commit, prepare-commit-msg, commit-msg, post-merge - # - am: pre-applypatch, applypatch-msg, post-applypatch - # - # Note: cherry-pick is NOT included here. While git 2.36+ added --no-verify - # for cherry-pick, older versions (including 2.34) reject it with a usage error. - # The primary protection (core.hooksPath=/dev/null) already covers cherry-pick. - # See issue #118. - if operation in ("commit", "merge", "am"): - validated_args = ["--no-verify", *validated_args] - - # SECURITY: Force-prepend `--no-deref` for `update-ref` (#2162). Without it, - # update-ref follows symref targets — the underlying ref is updated, not - # `refs/heads/`. In practice agent branches are never - # symrefs, but the gateway is a defense-in-depth boundary and the recovery - # flow never wants symref-following semantics. - if operation == "update-ref": - validated_args = ["--no-deref", *validated_args] - - # Build command - cmd = git_cmd(operation, *validated_args) - - # Set GIT_EDITOR=true so operations that need an editor (e.g., rebase - # --continue after conflict resolution) succeed without a terminal. - # `true` accepts the default commit message, which is the expected - # behavior for an agent that always provides messages via -m. - env = os.environ.copy() - env["GIT_EDITOR"] = "true" - - # Commit-authorship observer (#1882): snapshot HEAD before the git - # subcommand so we can compute which commits (if any) it created - # and register them with the orchestrator's authorship registry. - # Only agent sessions participate; internal gateway ops skip. - _observer_role: str | None = None - _observer_pipeline_id: str | None = None - _observer_repo: str | None = None - _observer_branch: str | None = None - _observer_before_head: str | None = None - _observer_armed: bool = False - _session_for_observer = getattr(g, "session", None) - if _session_for_observer is not None: - _observer_role = getattr(_session_for_observer, "agent_role", None) - _observer_pipeline_id = getattr(_session_for_observer, "pipeline_id", None) - _observer_repo = getattr(_session_for_observer, "repo", None) - _observer_branch = getattr(_session_for_observer, "assigned_branch", None) or getattr( - _session_for_observer, "branch", None - ) - # Intentionally exhaustive list of commit-creating operations. - # ``stash`` and ``pull`` can also create commit objects, but agents - # do not use them — all pushes go through the gateway's push handler - # which resolves attribution independently. Extend this list if - # agent workflows ever include stash or pull. - if _observer_role and operation in ( - "commit", - "merge", - "cherry-pick", - "revert", - "rebase", - "am", - ): - _observer_armed = True - _capture_head = _lookup_commit_observer_fn("capture_head") - if _capture_head is not None: - try: - _observer_before_head = _capture_head(exec_path) - except Exception: # pragma: no cover - defensive - # before_head stays None; observe handles the - # unborn-branch case via its [after_head] fallback. - _observer_before_head = None - - try: - result = subprocess.run( - cmd, - cwd=exec_path, - capture_output=True, - text=True, - timeout=60, - check=False, - env=env, - ) - - if result.returncode == 0: - # Fire the observer only on the narrow list of ref-mutating - # operations that armed the observer above. For all other - # operations (status, checkout, restore, ...) we skip the - # post-op rev-parse entirely so callers' subprocess - # mocking isn't perturbed. Note: _observer_before_head - # may be None on unborn branches — observe() handles that - # via its [after_head] fallback. - if _observer_role and _observer_armed: - try: - _observe_after = _lookup_commit_observer_fn("observe_after_git_execute") - if _observe_after is not None: - _observe_after( - exec_path, - before_head=_observer_before_head, - branch=_observer_branch, - session_role=_observer_role, - pipeline_id=_observer_pipeline_id, - repo=_observer_repo, - ) - except Exception: - # Observer is best-effort — never block the git - # response on a registry failure. - logger.debug( - "commit_observer_swallowed", - exc_info=True, - ) - audit_log( - "git_execute_success", - operation, - success=True, - details={ - "repo_path": repo_path, - "git_args": validated_args, - "container_id": container_id, - }, - ) - - # Detached-HEAD recovery hint (#2162). After a successful commit - # in a pipeline session, surface a clear hint if HEAD is detached - # so the agent doesn't spend minutes guessing at policy bypasses - # to update its work branch ref. - hint = _detached_head_hint(operation, exec_path, repo_path, container_id) - stderr_out = (result.stderr or "") + hint if hint else result.stderr - - return make_success( - f"git {operation} successful", - { - "stdout": result.stdout, - "stderr": stderr_out, - "returncode": result.returncode, - }, - ) - else: - # Check if this is an expected failure (e.g., repo detection queries) - # These happen when tools check if a directory is a git repo - is_expected_failure = result.stderr and ( - "not a git repository" in result.stderr - or "not inside a git repository" in result.stderr - ) - - if is_expected_failure: - # Log at debug level for expected failures - these are typically - # from tools probing to detect if they're in a git repo - logger.debug( - "Git operation failed (expected - not a git repository)", - operation=operation, - repo_path=repo_path, - container_id=container_id, - ) - else: - # Log at warning level for unexpected failures - audit_log( - "git_execute_failed", - operation, - success=False, - details={ - "repo_path": repo_path, - "git_args": validated_args, - "returncode": result.returncode, - "container_id": container_id, - "stderr": result.stderr[:500] if result.stderr else None, - }, - ) - - # Surface the detached-HEAD recovery hint on failure too. Common - # cases (rebase --onto mid-conflict, missing --allow-empty, index - # locks) produce a *failed* commit while detached, and the hint is - # exactly what cuts that confusion short. - failure_hint = _detached_head_hint(operation, exec_path, repo_path, container_id) - failure_stderr = (result.stderr or "") + failure_hint if failure_hint else result.stderr - return make_error( - f"git {operation} failed", - status_code=500, - details={ - "stdout": result.stdout, - "stderr": failure_stderr, - "returncode": result.returncode, - }, - ) - - except subprocess.TimeoutExpired: - return make_error(f"git {operation} timed out", status_code=504) - except Exception as e: - return make_error(f"git {operation} failed: {e}", status_code=500) - - -# ls-remote flags that take a separate-argument value (``--sort ``). -# Used by ``git_fetch`` to keep such a value adjacent to its flag when -# partitioning validated args into pre-URL flags and post-URL ref patterns, -# so the value is never stranded after the repository as a bogus ref pattern -# (#3484). ``--sort`` is the only value-taking flag in the ls-remote allowlist -# (``git_client._policy.GIT_ALLOWED_COMMANDS``); the rest are booleans. -LS_REMOTE_VALUE_FLAGS: frozenset[str] = frozenset({"--sort"}) - - -@app.route("/api/v1/git/fetch", methods=["POST"]) -@require_session_auth -def git_fetch() -> tuple[Response, int] | Response: - """ - Handle git fetch requests. - - Required because the container doesn't have direct access to GitHub tokens - (they are held by the gateway sidecar). This endpoint provides authenticated - fetch for git fetch, git ls-remote, and similar read operations. - - Request body: - { - "repo_path": "/path/to/repo", - "remote": "origin", - "args": ["--tags"] # optional additional args - } - - For ls-remote: - { - "repo_path": "/path/to/repo", - "operation": "ls-remote", - "remote": "origin", - "args": ["HEAD"] # optional refs to query - } - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - repo_path = data.get("repo_path") - remote = data.get("remote", "origin") - operation = data.get("operation", "fetch") # fetch or ls-remote - extra_args = data.get("args", []) - container_id = data.get("container_id") - - if not repo_path: - return make_error("Missing repo_path") - - # Validate repo_path to prevent path traversal attacks - path_valid, path_error = validate_repo_path(repo_path) - if not path_valid: - audit_log( - "fetch_blocked", - "git_fetch", - success=False, - details={"repo_path": repo_path, "reason": path_error}, - ) - return make_error(path_error, status_code=403) - - if operation not in ("fetch", "ls-remote"): - return make_error(f"Unsupported operation: {operation}") - - # Validate extra args against operation-specific allowlist - args_valid, args_error, validated_args = validate_git_args(operation, extra_args) - if not args_valid: - audit_log( - "fetch_blocked", - "git_fetch", - success=False, - details={"reason": args_error, "operation": operation}, - ) - return make_error(args_error, status_code=400) - - # Map container path to worktree path if container_id is provided - exec_path = map_container_path_to_worktree(repo_path, container_id, operation) - if exec_path is None: - return make_worktree_not_found_error(container_id) - - # Get remote URL to determine repo - remote_url, url_error = resolve_remote_url(remote, exec_path) - if url_error: - return make_error(url_error) - - # Extract repo from URL - repo = extract_repo_from_remote(remote_url) - if not repo: - return make_error(f"Could not parse repository from URL: {remote_url}") - - # Get session mode from request context (set by @require_session_auth decorator) - session_mode = getattr(g, "session_mode", None) - - # Check Private Repo Mode policy (if enabled) - repo_info = parse_owner_repo(repo) - if repo_info: - priv_result = check_private_repo_access( - operation=operation, - owner=repo_info.owner, - repo=repo_info.repo, - for_write=False, - session_mode=session_mode, - ) - if not priv_result.allowed: - audit_log( - f"{operation}_denied_private_mode", - f"git_{operation}", - success=False, - details={ - "repo": repo, - "reason": priv_result.reason, - "visibility": priv_result.visibility, - }, - ) - return make_error( - priv_result.reason, - status_code=403, - details=priv_result.to_dict(), - ) - - # Get authentication token using shared helper - token_str, auth_mode, token_error = get_token_for_repo(repo) - if not token_str: - return make_error(token_error, status_code=503) - - # Convert SSH URLs to HTTPS since gateway uses token auth - fetch_target = get_authenticated_remote_target(remote, remote_url) - if fetch_target != remote: - logger.debug( - f"Converting SSH URL to HTTPS for {operation}", - original_url=remote_url, - https_url=fetch_target, - ) - - # Build command using validated args - if operation == "fetch": - # Don't include remote when --all is specified (fetches from all remotes) - if "--all" in validated_args: - cmd_args = ["fetch"] + validated_args - else: - cmd_args = ["fetch", fetch_target] + validated_args - else: # ls-remote - # ``git ls-remote`` stops option parsing at the first positional - # argument: anything after is a pattern, not a - # flag. ``ls-remote --heads`` therefore filters by the - # literal pattern "--heads", matching nothing and exiting 0 with - # empty output (#3479: the stacked-PR reconciler read that empty - # listing as "every branch deleted" and hot-looped rebases of - # healthy PRs). Emit flags before the repository and ref patterns - # after it. - # - # A naive startswith("-") partition would strand a separate- - # argument flag *value* after the URL as a bogus ref pattern - # (#3484 review note 1): ``--sort committerdate`` is an allowlisted - # ls-remote flag whose value does not start with "-", so it would - # become ``ls-remote --sort committerdate`` — ``committerdate`` - # silently matching nothing. Keep such a value adjacent to its flag - # on the pre-URL side. No caller passes the separate-value form - # today; this hardens the route against a future footgun. - flags: list[str] = [] - patterns: list[str] = [] - arg_idx = 0 - while arg_idx < len(validated_args): - token = validated_args[arg_idx] - if not token.startswith("-"): - patterns.append(token) - arg_idx += 1 - continue - flags.append(token) - # Inline ``--sort=key`` is self-contained; only the separate - # ``--sort key`` form needs its value pulled along with it. - takes_separate_value = ( - token.split("=", 1)[0] in LS_REMOTE_VALUE_FLAGS and "=" not in token - ) - if ( - takes_separate_value - and arg_idx + 1 < len(validated_args) - and not validated_args[arg_idx + 1].startswith("-") - ): - flags.append(validated_args[arg_idx + 1]) - arg_idx += 2 - continue - arg_idx += 1 - cmd_args = ["ls-remote", *flags, fetch_target, *patterns] - - cmd = git_cmd(*cmd_args) - - # Create credential helper and execute operation - credential_helper_path = None - try: - credential_helper_path, env = create_credential_helper(token_str, os.environ.copy()) - - result = subprocess.run( - cmd, - cwd=exec_path, - capture_output=True, - text=True, - timeout=120, - env=env, - check=False, - ) - - if result.returncode == 0: - audit_log( - f"{operation}_success", - f"git_{operation}", - success=True, - details={ - "repo": repo, - "auth_mode": auth_mode, - }, - ) - return make_success( - f"{operation.capitalize()} successful", - { - "repo": repo, - "stdout": result.stdout, - "stderr": result.stderr, - "auth_mode": auth_mode, - }, - ) - else: - audit_log( - f"{operation}_failed", - f"git_{operation}", - success=False, - details={ - "repo": repo, - "returncode": result.returncode, - "auth_mode": auth_mode, - }, - ) - return make_error( - f"{operation.capitalize()} failed: {result.stderr}", - status_code=500, - details={"stdout": result.stdout, "stderr": result.stderr}, - ) - - except subprocess.TimeoutExpired: - _cleanup_stale_pack_files(exec_path) - return make_error(f"{operation.capitalize()} timed out", status_code=504) - except Exception as e: - return make_error(f"{operation.capitalize()} failed: {e}", status_code=500) - finally: - cleanup_credential_helper(credential_helper_path) - - -def _apply_pr_labels( - github: GitHubClient, - repo: str, - stdout: str, - auth_mode: str, - agent_role: str | None, - pipeline_id: str | None, -) -> None: - """Apply labels to a newly created PR. Failures are logged but non-fatal.""" - if not pipeline_id: - return - - # Extract PR number from URL like https://github.com/owner/repo/pull/42 - match = re.search(r"/pull/(\d+)", stdout or "") - if not match: - return - - pr_number = match.group(1) - labels = ["egg"] - if agent_role: - labels.append(f"agent:{agent_role}") - - try: - # Ensure labels exist (idempotent) - for label in labels: - github.execute( - ["label", "create", label, "--force", "--repo", repo], - timeout=15, - mode=auth_mode, - ) - # Apply labels to the PR - label_args = ["issue", "edit", pr_number, "--repo", repo] - for label in labels: - label_args.extend(["--add-label", label]) - github.execute(label_args, timeout=15, mode=auth_mode) - except Exception: - logger.warning( - "Failed to apply labels to PR", - pr_number=pr_number, - repo=repo, - labels=labels, - exc_info=True, - ) - - -@app.route("/api/v1/gh/pr/create", methods=["POST"]) -@require_session_auth -def gh_pr_create() -> tuple[Response, int] | Response: - """ - Create a pull request. - - Request body: - { - "repo": "owner/repo", - "title": "PR title", - "body": "PR body", - "base": "main", - "head": "feature-branch", - "draft": false (optional, forced to true in user mode) - } - - Policy: - - Bot mode: allowed (egg can create PRs) - - User mode: allowed (PRs are forced to draft mode) - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - repo = data.get("repo") - title = data.get("title") - body = data.get("body", "") - base = data.get("base") # None = gh uses repo's default branch - head = data.get("head") - - if not repo: - return make_error("Missing repo") - if not title: - return make_error("Missing title") - if not head: - return make_error("Missing head branch") - - # Determine auth mode for this repo - auth_mode = get_auth_mode(repo) - - # Get session mode from request context (set by @require_session_auth decorator) - session_mode = getattr(g, "session_mode", None) - - # Get session phase from request context (set by @require_session_auth decorator) - session_phase = getattr(g, "session_phase", None) - - # Check phase restrictions (if session has a phase set) - if session_phase: - try: - phase_result = filter_operation( - phase=session_phase, - operation_type=OperationType.GH, - command="pr create", - ) - if not phase_result.allowed: - audit_log( - "pr_create_blocked_phase", - "gh_pr_create", - success=False, - details={ - "repo": repo, - "phase": session_phase, - "reason": phase_result.blocked_reason, - }, - ) - return make_error( - phase_result.message, - status_code=403, - details={ - "phase": session_phase, - "blocked_reason": phase_result.blocked_reason, - }, - ) - except ValueError as e: - # Invalid phase value - log warning and allow (backward compat) - logger.warning( - "Invalid session phase value", - phase=session_phase, - error=str(e), - ) - else: - # No phase set - allow by default for backward compatibility - # Log a warning to track sessions without phase - logger.debug( - "PR create request from session without phase (backward compat)", - repo=repo, - ) - - # Check Private Repo Mode policy (if enabled) - repo_info = parse_owner_repo(repo) - if repo_info: - priv_result = check_private_repo_access( - operation="pr_create", - owner=repo_info.owner, - repo=repo_info.repo, - for_write=True, - session_mode=session_mode, - ) - if not priv_result.allowed: - audit_log( - "pr_create_denied_private_mode", - "gh_pr_create", - success=False, - details={ - "repo": repo, - "reason": priv_result.reason, - "visibility": priv_result.visibility, - "auth_mode": auth_mode, - }, - ) - return make_error( - priv_result.reason, - status_code=403, - details=priv_result.to_dict(), - ) - - # Policy check: PR creation may be blocked in reviewer mode - policy = get_policy_engine() - policy_result = policy.check_pr_create_allowed(repo, auth_mode=auth_mode) - if not policy_result.allowed: - audit_log( - "pr_create_blocked", - "gh_pr_create", - success=False, - details={ - "repo": repo, - "reason": policy_result.reason, - "auth_mode": auth_mode, - }, - ) - return make_error( - policy_result.reason, - status_code=403, - details=policy_result.details, - ) - - # In user mode, force PRs to be created as drafts - draft = data.get("draft", False) - if policy_result.details and policy_result.details.get("force_draft"): - draft = True - - # Inject machine-parseable pipeline metadata as an HTML comment so - # downstream tooling (status reporters, audit scrapers) can recover - # the pipeline_id / agent_role / issue from the PR body without - # round-tripping through the orchestrator state store. - session = getattr(g, "session", None) - session_pipeline_id = getattr(session, "pipeline_id", None) if session else None - if session_pipeline_id: - session_agent_role = getattr(session, "agent_role", None) or "" - session_issue_number = getattr(session, "issue_number", None) or "" - - # Sanitize values to prevent breaking the HTML comment structure - def _safe(v: str) -> str: - return str(v).replace("--", "").replace(">", "") - - metadata_comment = ( - f"" - ) - body = f"{body}\n\n{metadata_comment}" if body else metadata_comment - - try: - github = get_github_client(mode=auth_mode) - args = [ - "pr", - "create", - "--repo", - repo, - "--title", - title, - "--body", - body, - "--head", - head, - ] - - if base: - args.extend(["--base", base]) - - if draft: - args.append("--draft") - - result = github.execute(args, timeout=60, mode=auth_mode) - - if result.success: - # Apply labels to the newly created PR - _apply_pr_labels( - github=github, - repo=repo, - stdout=result.stdout, - auth_mode=auth_mode, - agent_role=getattr(session, "agent_role", None) if session else None, - pipeline_id=session_pipeline_id, - ) - - audit_log( - "pr_created", - "gh_pr_create", - success=True, - details={ - "repo": repo, - "title": title, - "base": base, - "head": head, - "auth_mode": auth_mode, - "draft": draft, - }, - ) - return make_success( - "PR created", - {"stdout": result.stdout, "stderr": result.stderr, "auth_mode": auth_mode}, - ) - else: - error_msg = result.stderr or "Unknown error" - audit_log( - "pr_create_failed", - "gh_pr_create", - success=False, - details={ - "repo": repo, - "error": error_msg[:200] if error_msg else "", - "auth_mode": auth_mode, - }, - ) - return make_error( - f"Failed to create PR: {error_msg}", - status_code=500, - details=result.to_dict(), - ) - except Exception as e: - logger.exception("Unexpected error in gh_pr_create") - return make_error(f"Internal error: {e}", status_code=500) - - -@app.route("/api/v1/gh/pr/comment", methods=["POST"]) -@require_session_auth -def gh_pr_comment() -> tuple[Response, int] | Response: - """ - Add a comment to a PR. - - Request body: - { - "repo": "owner/repo", - "pr_number": 123, - "body": "Comment text" - } - - Policy: pr_comment (allowed on any PR) - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - repo = data.get("repo") - pr_number = data.get("pr_number") - body = data.get("body") - - if not repo: - return make_error("Missing repo") - if not pr_number: - return make_error("Missing pr_number") - if not body: - return make_error("Missing body") - - # Determine auth mode for this repo - auth_mode = get_auth_mode(repo) - - # Get session mode from request context (set by @require_session_auth decorator) - session_mode = getattr(g, "session_mode", None) - - # Check Private Repo Mode policy (if enabled) - repo_info = parse_owner_repo(repo) - if repo_info: - priv_result = check_private_repo_access( - operation="pr_comment", - owner=repo_info.owner, - repo=repo_info.repo, - for_write=True, - session_mode=session_mode, - ) - if not priv_result.allowed: - audit_log( - "pr_comment_denied_private_mode", - "gh_pr_comment", - success=False, - details={ - "repo": repo, - "pr_number": pr_number, - "reason": priv_result.reason, - "visibility": priv_result.visibility, - "auth_mode": auth_mode, - }, - ) - return make_error( - priv_result.reason, - status_code=403, - details=priv_result.to_dict(), - ) - - # Check if commenting is allowed (allowed on any PR) - policy = get_policy_engine() - policy_result = policy.check_pr_comment_allowed(repo, pr_number, auth_mode=auth_mode) - - if not policy_result.allowed: - audit_log( - "pr_comment_denied", - "gh_pr_comment", - success=False, - details={ - "repo": repo, - "pr_number": pr_number, - "reason": policy_result.reason, - "auth_mode": auth_mode, - }, - ) - return make_error( - f"Comment denied: {policy_result.reason}", - status_code=403, - details=policy_result.details, - ) - - github = get_github_client(mode=auth_mode) - args = [ - "pr", - "comment", - str(pr_number), - "--repo", - repo, - "--body", - body, - ] - - result = github.execute(args, timeout=30, mode=auth_mode) - - if result.success: - audit_log( - "pr_comment_added", - "gh_pr_comment", - success=True, - details={"repo": repo, "pr_number": pr_number, "auth_mode": auth_mode}, - ) - return make_success("Comment added", {"stdout": result.stdout, "auth_mode": auth_mode}) - else: - return make_error( - f"Failed to add comment: {result.stderr}", - status_code=500, - details=result.to_dict(), - ) - - -@app.route("/api/v1/gh/pr/edit", methods=["POST"]) -@require_session_auth -def gh_pr_edit() -> tuple[Response, int] | Response: - """ - Edit a PR title, body, or base branch. - - Request body: - { - "repo": "owner/repo", - "pr_number": 123, - "title": "New title", # optional - "body": "New body", # optional - "base": "main" # optional — retarget the PR base - } - - At least one of ``title``, ``body``, or ``base`` must be set. - - The ``base`` field is the merge target branch ref (e.g. - ``main`` or ``egg/issue-N/slice-3``). It is the canonical - surface for the stacked-PR reconciler (#2137) to retarget a - child PR after the parent merges and the parent's branch is - deleted on origin. The ref is forwarded as-is to the GitHub - PATCH ``/repos/{owner}/{repo}/pulls/{pr_number}`` API. - - Policy: pr_ownership - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - repo = data.get("repo") - pr_number = data.get("pr_number") - title = data.get("title") - body = data.get("body") - base = data.get("base") - - if not repo: - return make_error("Missing repo") - if not pr_number: - return make_error("Missing pr_number") - if isinstance(pr_number, bool) or not isinstance(pr_number, int) or pr_number < 1: - return make_error("Invalid pr_number: must be a positive integer") - if not title and not body and not base: - return make_error("Must provide title, body, or base to edit") - if base is not None and (not isinstance(base, str) or not base.strip()): - return make_error("Invalid base: must be a non-empty branch ref") - - # Validate repo format early (before any API calls) - repo_info = parse_owner_repo(repo) - if not repo_info: - return make_error("Invalid repo format: expected 'owner/repo'") - - # Determine auth mode for this repo - auth_mode = get_auth_mode(repo) - - # Get session mode from request context (set by @require_session_auth decorator) - session_mode = getattr(g, "session_mode", None) - - # Check Private Repo Mode policy (if enabled) - priv_result = check_private_repo_access( - operation="pr_edit", - owner=repo_info.owner, - repo=repo_info.repo, - for_write=True, - session_mode=session_mode, - ) - if not priv_result.allowed: - audit_log( - "pr_edit_denied_private_mode", - "gh_pr_edit", - success=False, - details={ - "repo": repo, - "pr_number": pr_number, - "reason": priv_result.reason, - "visibility": priv_result.visibility, - "auth_mode": auth_mode, - }, - ) - return make_error( - priv_result.reason, - status_code=403, - details=priv_result.to_dict(), - ) - - # Check PR ownership (pass auth mode for relaxed policy in user mode) - policy = get_policy_engine() - policy_result = policy.check_pr_ownership(repo, pr_number, auth_mode=auth_mode) - - if not policy_result.allowed: - audit_log( - "pr_edit_denied", - "gh_pr_edit", - success=False, - details={ - "repo": repo, - "pr_number": pr_number, - "reason": policy_result.reason, - "auth_mode": auth_mode, - }, - ) - return make_error( - f"Edit denied: {policy_result.reason}", - status_code=403, - details=policy_result.details, - ) - - github = get_github_client(mode=auth_mode) - args = ["api", f"repos/{repo_info.owner}/{repo_info.repo}/pulls/{pr_number}", "-X", "PATCH"] - if title: - args.extend(["-f", f"title={title}"]) - if body: - args.extend(["-f", f"body={body}"]) - if base: - args.extend(["-f", f"base={base}"]) - - result = github.execute(args, timeout=30, mode=auth_mode) - - if result.success: - audit_log( - "pr_edited", - "gh_pr_edit", - success=True, - details={"repo": repo, "pr_number": pr_number, "auth_mode": auth_mode}, - ) - return make_success("PR edited", {"stdout": result.stdout, "auth_mode": auth_mode}) - else: - return make_error( - f"Failed to edit PR: {result.stderr}", - status_code=500, - details=result.to_dict(), - ) - - -@app.route("/api/v1/gh/pr/close", methods=["POST"]) -@require_session_auth -def gh_pr_close() -> tuple[Response, int] | Response: - """ - Close a PR. - - Request body: - { - "repo": "owner/repo", - "pr_number": 123 - } - - Policy: pr_ownership - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - repo = data.get("repo") - pr_number = data.get("pr_number") - - if not repo: - return make_error("Missing repo") - if not pr_number: - return make_error("Missing pr_number") - - # Determine auth mode for this repo - auth_mode = get_auth_mode(repo) - - # Get session mode from request context (set by @require_session_auth decorator) - session_mode = getattr(g, "session_mode", None) - - # Check Private Repo Mode policy (if enabled) - repo_info = parse_owner_repo(repo) - if repo_info: - priv_result = check_private_repo_access( - operation="pr_close", - owner=repo_info.owner, - repo=repo_info.repo, - for_write=True, - session_mode=session_mode, - ) - if not priv_result.allowed: - audit_log( - "pr_close_denied_private_mode", - "gh_pr_close", - success=False, - details={ - "repo": repo, - "pr_number": pr_number, - "reason": priv_result.reason, - "visibility": priv_result.visibility, - "auth_mode": auth_mode, - }, - ) - return make_error( - priv_result.reason, - status_code=403, - details=priv_result.to_dict(), - ) - - # Check PR ownership (pass auth mode for relaxed policy in user mode) - policy = get_policy_engine() - policy_result = policy.check_pr_ownership(repo, pr_number, auth_mode=auth_mode) - - if not policy_result.allowed: - audit_log( - "pr_close_denied", - "gh_pr_close", - success=False, - details={ - "repo": repo, - "pr_number": pr_number, - "reason": policy_result.reason, - "auth_mode": auth_mode, - }, - ) - return make_error( - f"Close denied: {policy_result.reason}", - status_code=403, - details=policy_result.details, - ) - - github = get_github_client(mode=auth_mode) - args = ["pr", "close", str(pr_number), "--repo", repo] - - result = github.execute(args, timeout=30, mode=auth_mode) - - if result.success: - audit_log( - "pr_closed", - "gh_pr_close", - success=True, - details={"repo": repo, "pr_number": pr_number, "auth_mode": auth_mode}, - ) - return make_success("PR closed", {"stdout": result.stdout, "auth_mode": auth_mode}) - else: - return make_error( - f"Failed to close PR: {result.stderr}", - status_code=500, - details=result.to_dict(), - ) - - -@app.route("/api/v1/gh/execute", methods=["POST"]) -@require_session_auth -def gh_execute() -> tuple[Response, int] | Response: - """ - Execute a generic gh command. - - Request body: - { - "args": ["pr", "view", "123"], - "cwd": "/path/to/repo" # optional - } - - Policy: Filtered - only read-only operations allowed by default. - Blocked commands return 403. - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - args = data.get("args", []) - cwd = data.get("cwd") - # Repo passed from container - container can detect repo from worktree, - # but gateway can't (different git structure) - payload_repo = data.get("repo") - - if not args: - return make_error("Missing args") - - # Get session mode from request context (set by @require_session_auth decorator) - session_mode = getattr(g, "session_mode", None) - - # Check for commands blocked entirely in private mode (too broad to filter by repo) - if session_mode == "private" and args and args[0] in GH_COMMANDS_BLOCKED_IN_PRIVATE_MODE: - audit_log( - "gh_command_blocked_private_mode", - "gh_execute", - success=False, - details={ - "command": args[0], - "reason": "Command blocked in private mode (too broad)", - }, - ) - return make_error( - f"Command 'gh {args[0]}' is not allowed in private mode", - status_code=403, - details={"command": args[0], "session_mode": "private"}, - ) - - # Check for blocked commands - cmd_str = " ".join(args[:2]) if len(args) >= 2 else args[0] if args else "" - - for blocked in BLOCKED_GH_COMMANDS: - if cmd_str.startswith(blocked): - audit_log( - "blocked_command", - "gh_execute", - success=False, - details={"command_args": args, "blocked_command": blocked}, - ) - return make_error( - f"Command '{blocked}' is not allowed through the gateway. " - f"Allowed: {', '.join(sorted(ALLOWED_GH_COMMANDS))}, api.", - status_code=403, - details={"blocked_command": blocked, "command_args": args}, - ) - - # --- Deny-by-default allowlist (parity with git_execute) --- - # A generic gh command must be on ALLOWED_GH_COMMANDS, or be `gh api` - # (further constrained below by GH_API_ALLOWED_PATHS). Anything else fails - # closed — this is what keeps credential-adjacent and otherwise - # unanticipated subcommands from executing by default. - gh_allowed, gh_cmd_key = is_gh_command_allowed(args) - if not gh_allowed: - audit_log( - "gh_command_not_allowed", - "gh_execute", - success=False, - details={"command_args": args, "command_key": gh_cmd_key}, - ) - _display_key = gh_cmd_key or "(no subcommand)" - return make_error( - f"Command 'gh {_display_key}' is not permitted through the gateway. " - f"Allowed: {', '.join(sorted(ALLOWED_GH_COMMANDS))}, api.", - status_code=403, - details={"command_key": gh_cmd_key, "command_args": args}, - ) - - # --- Phase and role-based operation filtering --- - # Block operations like "issue comment" / "issue edit" when phase or role restricts them. - # Build a command string from the first 3 non-flag args for matching. - # - # Normalize past any leading -R/--repo selector before constructing the - # command string used by the phase and role filters — parity with the - # overseer block below (line 4379) and the api-path guard further down - # (line 4541). Without this, an argv like `["-R", "owner/repo", "issue", - # "comment", "1032", "--body", "..."]` keys as `"owner/repo issue - # comment"`, which doesn't fnmatch `"issue comment *"` (phase filter) - # and doesn't `startswith("issue comment")` (_BLOCKED_GH_OPS), letting - # the role/phase enforcement be bypassed entirely. The allowlist check - # above already normalizes via `find_gh_command_index`, so doing the - # same here keeps the three positional-key call sites consistent. - _filter_cmd_idx = find_gh_command_index(args) - non_flag_args = [a for a in args[_filter_cmd_idx:] if not a.startswith("-")] - gh_command_str = " ".join(non_flag_args[:3]) - - session_phase = getattr(g, "session_phase", None) - if session_phase: - try: - phase_result = filter_operation( - phase=session_phase, - operation_type=OperationType.GH, - command=gh_command_str, - ) - if not phase_result.allowed: - audit_log( - "gh_execute_blocked_phase", - "gh_execute", - success=False, - details={ - "command": gh_command_str, - "phase": session_phase, - "reason": phase_result.blocked_reason, - }, - ) - return make_error( - phase_result.message, - status_code=403, - details={ - "phase": session_phase, - "blocked_reason": phase_result.blocked_reason, - }, - ) - except ValueError: - # Invalid phase value - allow for backward compat - logger.warning("Invalid session phase in gh_execute", phase=session_phase) - - # Role-based operation filtering — block agents from posting issue comments regardless of phase. - session_role = None - if hasattr(g, "session") and g.session: - _role = getattr(g.session, "agent_role", None) - if isinstance(_role, str) and _role: - session_role = _role - elif _role is not None and not isinstance(_role, str): - # Non-string agent_role — corrupted session, deny - return make_error( - "Invalid agent role type", - status_code=403, - details={"role": str(_role), "command": gh_command_str}, - ) - if session_role: - role_allowed, role_reason = check_agent_gh_operation(session_role, gh_command_str) - if not role_allowed: - audit_log( - "gh_execute_blocked_agent_role", - "gh_execute", - success=False, - details={ - "command": gh_command_str, - "role": session_role, - "reason": role_reason, - }, - ) - return make_error( - role_reason, - status_code=403, - details={"role": session_role, "command": gh_command_str}, - ) - - # Issue #1962 TASK-2-2: extra guardrails for `gh issue create` - # from the overseer role. The role-level check above does NOT - # block `gh issue create` from the overseer (the operation is - # not on _OVERSEER_BLOCKED_GH_OPS) so the existing handler lets - # it through. We now layer additional defenses on top: - # repo enforcement against EGG_PIPELINE_REPO, label injection, - # title/body size limits, and a defense-in-depth secret-pattern - # scan on the body. Failure is a structured 403. - # - # The guard looks past any leading `-R`/`--repo` selector via - # `find_gh_command_index` so an argv like - # `[-R owner/repo issue create --title ... --body ]` - # still runs the secret-pattern scan; otherwise the leading - # selector would put the `"issue"` token at args[2] instead of - # args[0], so an `args[0] == "issue"` check would miss it and - # the entire overseer block would be silently skipped (parity - # fix with the api-path guard below). - _overseer_cmd_idx = find_gh_command_index(args) - if ( - session_role - and session_role.lower() == "overseer" - and _overseer_cmd_idx + 1 < len(args) - and args[_overseer_cmd_idx] == "issue" - and args[_overseer_cmd_idx + 1] == "create" - ): - from .agent_restrictions import check_overseer_gh_issue_create - - # Parse the relevant flags from the gh argv. We accept both - # --title-file/--body-file (the new CLI verb's preferred path) - # and --title/--body (the historical form) so old callers do - # not break. Each known flag MUST be followed by a value that - # does not start with '-' (otherwise a malformed argv like - # `--repo --label foo` would consume `--label` as the repo - # value and walk past every subsequent flag — reviewer_code - # blocker against the original loop's order-dependence). - repo_arg: str | None = None - title_text: str = "" - body_text: str = "" - labels: list[str] = [] - _OVERSEER_VALUE_FLAGS = { - "--repo", - "--label", - "--title", - "--title-file", - "--body", - "--body-file", - } - - def _value_for(flag: str, idx: int) -> tuple[str | None, tuple[Response, int] | None]: - """Return (value, error_response) for a known --flag at args[idx].""" - if idx + 1 >= len(args): - return None, make_error( - f"Flag {flag!r} requires a value (end of argv)", - status_code=400, - details={"command": gh_command_str}, - ) - val = args[idx + 1] - if val.startswith("-"): - return None, make_error( - f"Flag {flag!r} requires a value (got another flag {val!r})", - status_code=400, - details={"command": gh_command_str}, - ) - return val, None - - # Start past the `issue create` tokens; `_overseer_cmd_idx` is the - # index of `"issue"`, so the flag walk begins at `_overseer_cmd_idx - # + 2`. With no leading selector this collapses to the original - # `i = 2`. - i = _overseer_cmd_idx + 2 - while i < len(args): - tok = args[i] - if tok in _OVERSEER_VALUE_FLAGS: - val, err = _value_for(tok, i) - if err is not None: - return err - if tok == "--repo": - repo_arg = val - elif tok == "--label": - labels.append(val or "") - elif tok == "--title": - title_text = val or "" - elif tok == "--title-file": - try: - with open(val or "", encoding="utf-8", errors="strict") as _f: - title_text = _f.read().strip() - except UnicodeDecodeError as _exc: - return make_error( - f"--title-file {val!r} contains invalid UTF-8: {_exc}", - status_code=400, - details={"command": gh_command_str}, - ) - except OSError as _exc: - return make_error( - f"Cannot read --title-file {val!r}: {_exc}", - status_code=400, - details={"command": gh_command_str}, - ) - elif tok == "--body": - body_text = val or "" - elif tok == "--body-file": - try: - # errors="strict" so invalid UTF-8 in the body - # is rejected loudly (reviewer_code blocker: - # silent corruption could swap a leaked-secret - # byte sequence past the regex check). - with open(val or "", encoding="utf-8", errors="strict") as _f: - body_text = _f.read() - except UnicodeDecodeError as _exc: - return make_error( - f"--body-file {val!r} contains invalid UTF-8: {_exc}", - status_code=400, - details={"command": gh_command_str}, - ) - except OSError as _exc: - return make_error( - f"Cannot read --body-file {val!r}: {_exc}", - status_code=400, - details={"command": gh_command_str}, - ) - i += 2 - continue - else: - i += 1 - - pipeline_repo = os.environ.get("EGG_PIPELINE_REPO") - ov_check = check_overseer_gh_issue_create( - role=session_role, - repo=repo_arg or "", - pipeline_repo=pipeline_repo, - labels=labels, - title=title_text, - body=body_text, - ) - if not ov_check.allowed: - audit_log( - "gh_overseer_issue_create_blocked", - "gh_execute", - success=False, - details={ - "command": gh_command_str, - "role": session_role, - "reason": ov_check.reason, - "secret_kinds": list(ov_check.secret_kinds), - }, - ) - return make_error( - ov_check.reason, - status_code=403, - details={ - "role": session_role, - "command": gh_command_str, - "secret_kinds": list(ov_check.secret_kinds), - }, - ) - # Auto-inject any required labels the caller forgot. The - # injected labels are tagged in the audit log so operators can - # spot bypass attempts. - if ov_check.injected_labels: - for lbl in ov_check.injected_labels: - args = (*args, "--label", lbl) - audit_log( - "gh_overseer_issue_create_labels_injected", - "gh_execute", - success=True, - details={ - "command": gh_command_str, - "role": session_role, - "injected_labels": list(ov_check.injected_labels), - }, - ) - - # For 'gh api' commands, validate the path against allowlist. - # Look past any leading -R/--repo selector so `gh -R owner/repo api /path` - # is still subjected to GH_API_ALLOWED_PATHS — otherwise the leading - # selector would shift args[0] off "api" and the path check would be - # silently skipped. - api_path: str | None = None - method: str = "GET" - _gh_cmd_idx = find_gh_command_index(args) - if _gh_cmd_idx < len(args) and args[_gh_cmd_idx] == "api" and len(args) > _gh_cmd_idx + 1: - # Parse arguments to find the actual API path (skip flags like -X, --method, etc.) - api_path, method = parse_gh_api_args(args[_gh_cmd_idx + 1 :]) - if api_path is None: - audit_log( - "api_path_missing", - "gh_execute", - success=False, - details={"command_args": args}, - ) - return make_error("No API path provided in gh api command", status_code=400) - - # Resolve {owner} and {repo} template variables if present - # The gh CLI resolves these from the current repo's git remote - resolved_api_path = resolve_gh_api_template_variables(api_path, cwd) - if resolved_api_path is None: - audit_log( - "api_path_template_resolution_failed", - "gh_execute", - success=False, - details={ - "api_path": api_path, - "cwd": cwd, - "reason": "Could not resolve template variables", - }, - ) - return make_error( - "Could not resolve {owner}/{repo} template variables. " - "Ensure you are in a git repository with an 'origin' remote.", - status_code=400, - ) - - # If template variables were resolved, update the args to use resolved path - if resolved_api_path != api_path: - # Find and replace the API path in args - args = list(args) # Make a mutable copy - for i, arg in enumerate(args): - if arg == api_path: - args[i] = resolved_api_path - break - api_path = resolved_api_path - - path_valid, path_error = validate_gh_api_path(api_path, method) - if not path_valid: - audit_log( - "api_path_blocked", - "gh_execute", - success=False, - details={"api_path": api_path, "method": method, "reason": path_error}, - ) - return make_error(path_error, status_code=403) - - # Detect issue comment/edit via gh api (bypass prevention). - # These API calls are equivalent to "gh issue comment/edit {id}" — - # apply the same phase + role checks. - synthesized_cmd = None - - # POST to repos/{owner}/{repo}/issues/{id}/comments → issue comment - _api_issue_comment_match = re.match(r"^repos/[^/]+/[^/]+/issues/(\d+)/comments$", api_path) - if _api_issue_comment_match and method.upper() == "POST": - synthesized_cmd = f"issue comment {_api_issue_comment_match.group(1)}" - - # PATCH to repos/{owner}/{repo}/issues/{id} → issue edit - _api_issue_edit_match = re.match(r"^repos/[^/]+/[^/]+/issues/(\d+)$", api_path) - if _api_issue_edit_match and method.upper() == "PATCH": - synthesized_cmd = f"issue edit {_api_issue_edit_match.group(1)}" - - if synthesized_cmd: - # Phase check - if session_phase: - try: - api_phase_result = filter_operation( - phase=session_phase, - operation_type=OperationType.GH, - command=synthesized_cmd, - ) - if not api_phase_result.allowed: - audit_log( - "gh_api_issue_op_blocked_phase", - "gh_execute", - success=False, - details={ - "api_path": api_path, - "synthesized_command": synthesized_cmd, - "phase": session_phase, - }, - ) - return make_error( - api_phase_result.message, - status_code=403, - details={ - "phase": session_phase, - "blocked_reason": api_phase_result.blocked_reason, - }, - ) - except ValueError: - pass - # Role check - if session_role: - api_role_allowed, api_role_reason = check_agent_gh_operation( - session_role, synthesized_cmd - ) - if not api_role_allowed: - audit_log( - "gh_api_issue_op_blocked_role", - "gh_execute", - success=False, - details={ - "api_path": api_path, - "role": session_role, - "reason": api_role_reason, - }, - ) - return make_error( - api_role_reason, - status_code=403, - details={"role": session_role, "api_path": api_path}, - ) - - # Extract repo using comprehensive extractor (handles --repo, gh repo *, gh api paths) - repo = extract_repo_from_gh_command(args) - - # Fall back to payload_repo if command doesn't contain repo - if not repo and payload_repo: - repo = payload_repo - # Inject --repo into args so gh command uses it - # NOTE: Don't inject for commands that don't support --repo flag: - # - 'gh repo' commands - they take repo as positional arg - # - 'gh auth' commands - global commands, no repo context - # - 'gh config' commands - global commands, no repo context - # - 'gh api' commands - repo is in the API path, not a flag - commands_without_repo_flag = {"repo", "auth", "config", "api"} - if args and args[0] not in commands_without_repo_flag: - args = ["--repo", payload_repo] + list(args) - - # Determine auth mode (default to bot if repo not specified) - auth_mode = get_auth_mode(repo) if repo else "bot" - - # Check Private Repo Mode policy (if enabled and repo is known) - if repo: - repo_info = parse_owner_repo(repo) - if repo_info: - priv_result = check_private_repo_access( - operation="gh_execute", - owner=repo_info.owner, - repo=repo_info.repo, - for_write=False, # Assume read for generic gh execute - session_mode=session_mode, - ) - if not priv_result.allowed: - audit_log( - "gh_execute_denied_private_mode", - "gh_execute", - success=False, - details={ - "repo": repo, - "command_args": args[:3] if len(args) > 3 else args, - "reason": priv_result.reason, - "visibility": priv_result.visibility, - "auth_mode": auth_mode, - }, - ) - return make_error( - priv_result.reason, - status_code=403, - details=priv_result.to_dict(), - ) - - # Use reviewer token for PR reviews when available. This allows the - # reviewer bot (a separate GitHub App) to post approve/request-changes - # on PRs authored by the main bot — something the bot can't do on its own PRs. - # This applies to both bot and user modes since the reviewer token is a - # separate identity specifically for reviews. - # Note: args may have "--repo owner/repo" prepended, so we check if "pr" and "review" - # appear in sequence anywhere in the args (not just at positions 0 and 1). - def is_pr_review_command(cmd_args: list[str]) -> bool: - for i in range(len(cmd_args) - 1): - if cmd_args[i] == "pr" and cmd_args[i + 1] == "review": - return True - return False - - if is_pr_review_command(args) and auth_mode in ("bot", "user"): - try: - from token_refresher import is_reviewer_token_available - - if is_reviewer_token_available(): - auth_mode = "reviewer" - logger.info("Using reviewer token for pr review command") - else: - logger.debug( - "Reviewer token not available, using %s token for pr review", auth_mode - ) - except ImportError: - pass - - # For mutating operations on specific resources via gh api, verify ownership - if api_path is not None: - policy = get_policy_engine() - - # PATCH on comment endpoints — verify bot/configured user owns the comment - comment_info = extract_comment_edit_info(api_path, method) - if comment_info: - c_owner, c_repo_name, c_comment_id, c_comment_type = comment_info - ownership_result = policy.check_comment_ownership( - f"{c_owner}/{c_repo_name}", - c_comment_id, - c_comment_type, - auth_mode=auth_mode, - ) - if not ownership_result.allowed: - audit_log( - "comment_edit_denied", - "gh_execute", - success=False, - details={ - "api_path": api_path, - "comment_id": c_comment_id, - "comment_type": c_comment_type, - "reason": ownership_result.reason, - }, - ) - return make_error( - ownership_result.reason, - status_code=403, - details=ownership_result.to_dict(), - ) - - # POST/PATCH on issue labels — verify bot/configured user owns the issue/PR - label_info = extract_issue_label_info(api_path, method) - if label_info: - l_owner, l_repo_name, l_issue_number = label_info - ownership_result = policy.check_issue_ownership( - f"{l_owner}/{l_repo_name}", - l_issue_number, - auth_mode=auth_mode, - ) - if not ownership_result.allowed: - audit_log( - "label_edit_denied", - "gh_execute", - success=False, - details={ - "api_path": api_path, - "issue_number": l_issue_number, - "reason": ownership_result.reason, - }, - ) - return make_error( - ownership_result.reason, - status_code=403, - details=ownership_result.to_dict(), - ) - - # POST on PR requested reviewers — verify bot/configured user owns the PR - reviewer_info = extract_pr_reviewer_info(api_path, method) - if reviewer_info: - r_owner, r_repo_name, r_pr_number = reviewer_info - ownership_result = policy.check_pr_ownership( - f"{r_owner}/{r_repo_name}", - r_pr_number, - auth_mode=auth_mode, - ) - if not ownership_result.allowed: - audit_log( - "reviewer_edit_denied", - "gh_execute", - success=False, - details={ - "api_path": api_path, - "pr_number": r_pr_number, - "reason": ownership_result.reason, - }, - ) - return make_error( - ownership_result.reason, - status_code=403, - details=ownership_result.to_dict(), - ) - - # POST on PR reviews — verify PR exists and review is allowed - review_info = extract_pr_review_info(api_path, method) - if review_info: - rv_owner, rv_repo_name, rv_pr_number = review_info - review_result = policy.check_pr_review_allowed( - f"{rv_owner}/{rv_repo_name}", - rv_pr_number, - auth_mode=auth_mode, - ) - if not review_result.allowed: - audit_log( - "review_create_denied", - "gh_execute", - success=False, - details={ - "api_path": api_path, - "pr_number": rv_pr_number, - "reason": review_result.reason, - }, - ) - return make_error( - review_result.reason, - status_code=403, - details=review_result.to_dict(), - ) - - # Execute the command - github = get_github_client(mode=auth_mode) - result = github.execute(args, timeout=60, cwd=cwd, mode=auth_mode) - - if result.success: - response_data = result.to_dict() - response_data["auth_mode"] = auth_mode - return make_success("Command executed", response_data) - else: - return make_error( - f"Command failed: {result.stderr}", - status_code=500, - details=result.to_dict(), - ) - - -@app.route("/api/v1/gh/find_open_pr", methods=["POST"]) -@require_launcher_auth -def gh_find_open_pr() -> tuple[Response, int] | Response: - """Control-plane idempotency lookup: return the open ``head → base`` PR number. - - This is an **orchestrator-only** route, gated by ``@require_launcher_auth`` - rather than ``@require_session_auth``: the caller is the control plane - (the orchestrator holds the launcher secret), not a sandboxed agent. It - exists so the orchestrator's slice-PR idempotency pre-flight (#2777 cq-8) - does not have to register a synthetic *agent* session and impersonate a - role on ``/api/v1/gh/execute`` — the conflation that #2893 papered over by - adding a bogus ``AgentRole.ORCHESTRATOR``. The orchestrator is not an - agent role; it is the server that manages pipelines, so it authenticates - as the control plane and uses a purpose-built read-only endpoint. - - Unlike ``/api/v1/gh/execute`` (arbitrary allowlisted argv), this route - accepts only ``repo``/``head``/``base`` and constructs the fixed - read-only argv server-side, so there is no general gh-command surface on - the launcher-auth path. - - Request body: - {"repo": "owner/name", "head": "", "base": ""} - - Returns: - ``{"number": }`` on hit, ``{"number": null}`` on miss. The GH - API documents at most one open PR per (head, base) tuple, so the - lookup is ``--limit 1``. - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - # Validate and bind stripped values in one pass so mypy sees ``repo`` - # / ``head`` / ``base`` as ``str`` (not the ``Any`` returned by - # ``data.get(...)``) below. - fields: dict[str, str] = {} - for name, value in ( - ("repo", data.get("repo")), - ("head", data.get("head")), - ("base", data.get("base")), - ): - if not isinstance(value, str) or not value.strip(): - return make_error(f"Missing or invalid {name}: must be a non-empty string") - fields[name] = value.strip() - repo, head, base = fields["repo"], fields["head"], fields["base"] - - # ``OWNER_REPO_PATTERN`` is stricter than ``parse_owner_repo`` (which - # also accepts full GitHub URLs); the docstring and the validation - # error below both promise the literal ``owner/name`` shape, so we - # match against the pattern directly rather than the URL-permissive - # helper. - if OWNER_REPO_PATTERN.match(repo) is None: - return make_error("Invalid repo: must be 'owner/name'") - - args = [ - "pr", - "list", - "--repo", - repo, - "--head", - head, - "--base", - base, - "--state", - "open", - "--limit", - "1", - "--json", - "number", - ] - - auth_mode = get_auth_mode(repo) - github = get_github_client(mode=auth_mode) - result = github.execute(args, timeout=60, mode=auth_mode) - - if not result.success: - # ``gh`` should not print credentials to stderr, but truncate - # defensively so we never page a giant stderr blob into the - # audit log. - stderr_excerpt = (result.stderr or "")[:500] - audit_log( - "gh_find_open_pr_failed", - "gh_find_open_pr", - success=False, - details={"repo": repo, "head": head, "base": base, "stderr": stderr_excerpt}, - ) - return make_error( - f"Command failed: {result.stderr}", - status_code=500, - details=result.to_dict(), - ) - - number: int | None = None - stdout = (result.stdout or "").strip() - if stdout: - try: - items = json.loads(stdout) - except ValueError, TypeError: - items = None - if isinstance(items, list): - for item in items: - if isinstance(item, dict) and item.get("number") is not None: - try: - number = int(item["number"]) - except TypeError, ValueError: - number = None - break - - audit_log( - "gh_find_open_pr", - "gh_find_open_pr", - success=True, - details={"repo": repo, "head": head, "base": base, "number": number}, - ) - return make_success("Open PR lookup complete", {"number": number}) - - -@app.route("/api/v1/gh/list_open_prs", methods=["POST"]) -@require_launcher_auth -def gh_list_open_prs() -> tuple[Response, int] | Response: - """Control-plane listing: return the repo's open PRs (number/head/base). - - Like ``/api/v1/gh/find_open_pr``, this is an **orchestrator-only** - route gated by ``@require_launcher_auth`` rather than - ``@require_session_auth``: the caller is the control plane (the - orchestrator holds the launcher secret), not a sandboxed agent. It - exists so the orchestrator's context-PR idempotency pre-flight - (``_open_context_pr_at_implement_start``) and stacked-PR reconciler - do not have to register a synthetic *agent* session and impersonate a - role on ``/api/v1/gh/execute`` — the conflation #2910 papered over by - adding a bogus ``AgentRole.ORCHESTRATOR`` (removed in #2925). The - orchestrator is not an agent role; it is the server that manages - pipelines, so it authenticates as the control plane and uses a - purpose-built read-only endpoint. - - Unlike ``/api/v1/gh/execute`` (arbitrary allowlisted argv), this route - accepts only ``repo``/``limit`` and constructs the fixed read-only - argv server-side, so there is no general gh-command surface on the - launcher-auth path. - - Request body: - {"repo": "owner/name", "limit": } - - Returns: - ``{"prs": [{"number": int, "headRefName": str, "baseRefName": str}, ...]}``. - The caller (``GatewayClient.list_open_prs``) normalises this into - the ``number``/``head_ref``/``base_ref`` shape its consumers expect. - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - # ``request.get_json()`` returns whatever JSON parses — a launcher - # caller could legitimately post an array or scalar. Reject anything - # other than an object up front so ``data.get(...)`` below cannot - # raise ``AttributeError`` → 500. - if not isinstance(data, dict): - return make_error("Invalid body: must be a JSON object") - - repo = data.get("repo") - if not isinstance(repo, str) or not repo.strip(): - return make_error("Missing or invalid repo: must be a non-empty string") - repo = repo.strip() - - # ``OWNER_REPO_PATTERN`` is stricter than ``parse_owner_repo`` (which - # also accepts full GitHub URLs); the docstring promises the literal - # ``owner/name`` shape, so we match against the pattern directly. - if OWNER_REPO_PATTERN.match(repo) is None: - return make_error("Invalid repo: must be 'owner/name'") - - # ``bool`` is a subclass of ``int``; reject it explicitly so ``True`` - # cannot slip through as ``limit=1``. - limit = data.get("limit", 200) - if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000: - return make_error("Invalid limit: must be an integer in [1, 1000]") - - args = [ - "pr", - "list", - "--repo", - repo, - "--state", - "open", - "--limit", - str(limit), - "--json", - "number,headRefName,baseRefName", - ] - - auth_mode = get_auth_mode(repo) - github = get_github_client(mode=auth_mode) - result = github.execute(args, timeout=60, mode=auth_mode) - - if not result.success: - # ``gh`` should not print credentials to stderr, but truncate - # defensively so we never page a giant stderr blob into the - # audit log. - stderr_excerpt = (result.stderr or "")[:500] - audit_log( - "gh_list_open_prs_failed", - "gh_list_open_prs", - success=False, - details={"repo": repo, "limit": limit, "stderr": stderr_excerpt}, - ) - return make_error( - f"Command failed: {result.stderr}", - status_code=500, - details=result.to_dict(), - ) - - prs: list[dict[str, Any]] = [] - stdout = (result.stdout or "").strip() - if stdout: - try: - items = json.loads(stdout) - except ValueError, TypeError: - items = None - if isinstance(items, list): - prs = [item for item in items if isinstance(item, dict)] - - audit_log( - "gh_list_open_prs", - "gh_list_open_prs", - success=True, - details={"repo": repo, "limit": limit, "count": len(prs)}, - ) - return make_success("Open PR list complete", {"prs": prs}) - - -@app.route("/api/v1/gh/pr/merge_state", methods=["POST"]) -@require_launcher_auth -def gh_pr_merge_state() -> tuple[Response, int] | Response: - """Control-plane PR merge-state read: return ``state`` + ``mergedAt`` (#3393). - - An **orchestrator-only** route gated by ``@require_launcher_auth`` - rather than ``@require_session_auth`` — the caller is the control - plane (the orchestrator holds the launcher secret), not a sandboxed - agent. It is the read half of the cq-1 cross-repo merge-sequencing - gate: the orchestrator polls an upstream slice PR's merge state to - decide when to mark a downstream draft PR ready. Modelled on - ``gh_find_open_pr`` / ``gh_list_open_prs`` (#2925): the orchestrator - is the server that manages pipelines, not an ``AgentRole``, so it - authenticates as the control plane and uses a purpose-built, - fixed-argv read-only endpoint (no general gh surface here). - - Merge detection deliberately keys off the PR's ``mergedAt`` / - ``state`` — NOT head-SHA equality: a squash/rebase merge produces a - merge-commit SHA that differs from the PR head, so a SHA comparison - would misfire (#3393 task-5-1 pin (a)). - - Request body: - {"repo": "owner/name", "pr_number": } - - Returns: - ``{"state": "OPEN|CLOSED|MERGED"|null, "mergedAt": ""|null}``. - """ - data = request.get_json() - if not data or not isinstance(data, dict): - return make_error("Invalid body: must be a JSON object") - - repo = data.get("repo") - if not isinstance(repo, str) or not repo.strip(): - return make_error("Missing or invalid repo: must be a non-empty string") - repo = repo.strip() - if OWNER_REPO_PATTERN.match(repo) is None: - return make_error("Invalid repo: must be 'owner/name'") - - # ``bool`` is a subclass of ``int``; reject it explicitly so ``True`` - # cannot slip through as ``pr_number=1``. - pr_number = data.get("pr_number") - if isinstance(pr_number, bool) or not isinstance(pr_number, int) or pr_number < 1: - return make_error("Invalid pr_number: must be a positive integer") - - args = [ - "pr", - "view", - str(pr_number), - "--repo", - repo, - "--json", - "state,mergedAt", - ] - - auth_mode = get_auth_mode(repo) - github = get_github_client(mode=auth_mode) - result = github.execute(args, timeout=60, mode=auth_mode) - - if not result.success: - stderr_excerpt = (result.stderr or "")[:500] - audit_log( - "gh_pr_merge_state_failed", - "gh_pr_merge_state", - success=False, - details={"repo": repo, "pr_number": pr_number, "stderr": stderr_excerpt}, - ) - return make_error( - f"Command failed: {result.stderr}", - status_code=500, - details=result.to_dict(), - ) - - state_val: Any = None - merged_at: Any = None - stdout = (result.stdout or "").strip() - if stdout: - try: - parsed = json.loads(stdout) - except ValueError, TypeError: - parsed = None - if isinstance(parsed, dict): - state_val = parsed.get("state") - merged_at = parsed.get("mergedAt") - - audit_log( - "gh_pr_merge_state", - "gh_pr_merge_state", - success=True, - details={"repo": repo, "pr_number": pr_number, "state": state_val}, - ) - return make_success( - "PR merge-state lookup complete", - {"state": state_val, "mergedAt": merged_at}, - ) - - -@app.route("/api/v1/gh/pr/ready", methods=["POST"]) -@require_launcher_auth -def gh_pr_ready() -> tuple[Response, int] | Response: - """Control-plane PR draft→ready transition: wrap ``gh pr ready`` (#3393). - - An **orchestrator-only** route gated by ``@require_launcher_auth`` — - the write half of the cq-1 cross-repo merge-sequencing gate. When the - upstream slice PR merges, the orchestrator transitions the downstream - cross-repo dependent PR from draft to ready. Like the sibling - control-plane PR routes (``gh_find_open_pr`` / ``gh_list_open_prs``), - the caller is the control plane, so it authenticates with the - launcher secret and this route constructs a **fixed, narrow argv** - server-side (``pr ready --repo ``) — there is no arbitrary - gh-command surface on the launcher-auth path, only this single - ready-transition. ``pr ready`` is already on ``ALLOWED_GH_COMMANDS`` - (github_client.py) so the underlying ``gh`` invocation re-validates - through the same allowlist floor. - - Request body: - {"repo": "owner/name", "pr_number": } - - Returns: - ``{"stdout": ""}`` on success. - """ - data = request.get_json() - if not data or not isinstance(data, dict): - return make_error("Invalid body: must be a JSON object") - - repo = data.get("repo") - if not isinstance(repo, str) or not repo.strip(): - return make_error("Missing or invalid repo: must be a non-empty string") - repo = repo.strip() - if OWNER_REPO_PATTERN.match(repo) is None: - return make_error("Invalid repo: must be 'owner/name'") - - pr_number = data.get("pr_number") - if isinstance(pr_number, bool) or not isinstance(pr_number, int) or pr_number < 1: - return make_error("Invalid pr_number: must be a positive integer") - - args = [ - "pr", - "ready", - str(pr_number), - "--repo", - repo, - ] - - auth_mode = get_auth_mode(repo) - github = get_github_client(mode=auth_mode) - result = github.execute(args, timeout=60, mode=auth_mode) - - if not result.success: - stderr_excerpt = (result.stderr or "")[:500] - audit_log( - "gh_pr_ready_failed", - "gh_pr_ready", - success=False, - details={"repo": repo, "pr_number": pr_number, "stderr": stderr_excerpt}, - ) - return make_error( - f"Failed to mark PR ready: {result.stderr}", - status_code=500, - details=result.to_dict(), - ) - - audit_log( - "gh_pr_ready", - "gh_pr_ready", - success=True, - details={"repo": repo, "pr_number": pr_number}, - ) - return make_success("PR marked ready", {"stdout": result.stdout}) - - -# ============================================================================= -# Jira REST Endpoints -# ============================================================================= -# -# Read-only wrappers around Atlassian Cloud's REST API v3. Routes live on -# the ``/api/v1/jira/*`` prefix and mirror the shape of ``/api/v1/gh/*``: -# session auth, private-mode gate, project allowlist, structured audit log. -# -# Credentials come from ``gateway/jira_credentials.py`` (loaded from the same -# ``secrets.env`` file as the GitHub and Anthropic credentials) and are -# never exported to the sandbox. See: -# - gateway/jira_client.py — client + path allowlist -# - gateway/jira_policy.py — project allowlist loader -# - gateway/jira_search.py — JQL project-scope extractor -# - gateway/mode_gate.py — @require_private_mode decorator - -# Regex for the Jira ticket-key shape agents are allowed to pass in -# ``/api/v1/jira/ticket/*`` request bodies. ``jira_client`` does its own -# allowlist check on the full REST path, but we validate the shape here so -# the error message is actionable before we ever look at the client. -_JIRA_TICKET_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*-\d+$") -_JIRA_PROJECT_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") - - -def _session_jira_context() -> dict[str, Any]: - """Return session-scoped fields to include in Jira audit records. - - Pipeline ID, agent role, and the new ``jira_ticket`` are observational - — they aren't used as policy gates (the project allowlist is the only - hard boundary — refine decision #9) but they make the audit trail - self-describing. - """ - ctx: dict[str, Any] = { - "session_mode": getattr(g, "session_mode", None), - } - session = getattr(g, "session", None) - if session is not None: - ctx["pipeline_id"] = getattr(session, "pipeline_id", None) - ctx["agent_role"] = getattr(session, "agent_role", None) - ctx["jira_ticket"] = getattr(session, "jira_ticket", None) - return ctx - - -def _jira_error_from_upstream(exc: JiraUpstreamError) -> tuple[Response, int]: - """Translate a ``JiraUpstreamError`` to an HTTP response. - - Atlassian status codes in the 4xx range are passed through so the agent - sees the real reason; 5xx upstream errors collapse to a 502 with the - raw body in the audit trail. - """ - if 400 <= exc.status_code < 500: - status = exc.status_code - else: - status = 502 - return make_error( - f"Jira upstream error {exc.status_code}", - status_code=status, - details={ - "upstream_status": exc.status_code, - "upstream_body": exc.body, - "path": exc.path, - }, - ) - - -def _jira_not_configured_error(exc: JiraCredentialsUnavailable) -> tuple[Response, int]: - """Translate missing credentials to an HTTP 503 response.""" - return make_error( - "Jira credentials not configured on the gateway", - status_code=503, - details={"reason": str(exc)}, - ) - - -def _project_not_allowlisted_response( - *, - event: str, - ticket: str | None, - project: str | None, - reason: str, - extra: dict[str, Any] | None = None, -) -> tuple[Response, int]: - """Emit a structured audit record and return the canonical 403.""" - details: dict[str, Any] = {"project": project, "reason": reason} - if ticket is not None: - details["ticket"] = ticket - if extra: - details.update(extra) - details.update(_session_jira_context()) - audit_log(event, event, success=False, details=details) - return make_error( - "Jira project not allowlisted", - status_code=403, - details={"project": project, "reason": reason}, - ) - - -@app.route("/api/v1/jira/ticket/get", methods=["POST"]) -# Issue #1557 reviewer_code v1 finding #1: accept either a session -# token (agent path) or the launcher secret (orchestrator-internal -# path used by ``orchestrator.jira_epic.is_epic_for_ticket`` at -# submit-task time). ``require_private_mode`` is patched to accept -# ``g.auth_actor == 'launcher'`` so the orchestrator-only call -# does not get rejected by the agent-facing private-mode gate. -@require_session_or_launcher_auth -@require_private_mode -def jira_ticket_get() -> tuple[Response, int] | Response: - """Fetch a single Jira issue. - - Request body:: - - {"ticket": "FOO-123", "fields": ["summary", "status"]} - - ``fields`` is optional; when omitted, Atlassian returns the default field - set. ``expand`` defaults to ``renderedBody,renderedFields`` in the - client so agents receive both ADF and rendered HTML. - """ - data = request.get_json(silent=True) or {} - ticket = data.get("ticket") - fields = data.get("fields") - - if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): - audit_log( - "jira_ticket_get_rejected", - "jira_ticket_get", - success=False, - details={"reason": "invalid ticket shape", "ticket": ticket, **_session_jira_context()}, - ) - return make_error( - "Invalid ticket key (expected e.g. 'FOO-123')", - status_code=400, - details={"ticket": ticket}, - ) - - project = extract_project_key(ticket) - if not is_project_allowed(project): - return _project_not_allowlisted_response( - event="jira_ticket_get_denied", - ticket=ticket, - project=project, - reason="project not allowlisted", - ) - - try: - cleaned_fields = validate_jira_fields(fields) - except ValueError as exc: - audit_log( - "jira_ticket_get_rejected", - "jira_ticket_get", - success=False, - details={"reason": str(exc), "ticket": ticket, **_session_jira_context()}, - ) - return make_error(f"Invalid fields: {exc}", status_code=400) - - try: - body = get_jira_client().get_ticket(ticket, cleaned_fields or None) - except JiraCredentialsUnavailable as exc: - return _jira_not_configured_error(exc) - except JiraUpstreamError as exc: - audit_log( - "jira_ticket_get_upstream_error", - "jira_ticket_get", - success=False, - details={ - "ticket": ticket, - "project": project, - "upstream_status": exc.status_code, - **_session_jira_context(), - }, - ) - return _jira_error_from_upstream(exc) - - audit_log( - "jira_ticket_get", - "jira_ticket_get", - success=True, - details={ - "ticket": ticket, - "project": project, - "not_found": body.get("status") == "not_found", - **_session_jira_context(), - }, - ) - return make_success("Jira ticket fetched", body) - - -@app.route("/api/v1/jira/search", methods=["POST"]) -# Issue #1557 reviewer_code v1 finding #1: same launcher-auth -# bypass as ``/api/v1/jira/ticket/get`` — the reassess sweep in -# ``orchestrator.jira_reassess.run_reassess_sweep`` uses the -# launcher secret to enumerate epic children. -@require_session_or_launcher_auth -@require_private_mode -def jira_search() -> tuple[Response, int] | Response: - """Run a JQL query against Atlassian Cloud. - - Request body:: - - {"jql": "project = ENG AND status = Open", - "fields": [...], - "nextPageToken": "...", - "maxResults": 50} - - The JQL must be statically provable as scoped to allowlisted projects. - See ``gateway/jira_search.py`` for the exact acceptance rules. - """ - data = request.get_json(silent=True) or {} - jql = data.get("jql") - fields = data.get("fields") - next_page_token = data.get("nextPageToken") - max_results = data.get("maxResults") - - if not isinstance(jql, str) or not jql.strip(): - audit_log( - "jira_search_rejected", - "jira_search", - success=False, - details={"reason": "jql required", **_session_jira_context()}, - ) - return make_error("jql is required", status_code=400) - - # Import allowlist lazily because ``allowed_projects`` resolves the - # policy singleton on first access. Getting the frozenset once per - # request keeps the mtime check out of the hot path for tests that - # monkeypatch ``is_project_allowed`` directly. - try: - from .jira_policy import allowed_projects - except ImportError: - from jira_policy import allowed_projects # type: ignore[no-redef] - allowed = allowed_projects() - - scope = extract_search_projects(jql, allowed) - if scope.projects is None: - audit_log( - "jira_search_rejected", - "jira_search", - success=False, - details={ - "reason": scope.reason, - "jql_length": len(jql), - **_session_jira_context(), - }, - ) - return make_error( - f"JQL rejected: {scope.reason}", - status_code=403, - details={"reason": scope.reason}, - ) - - try: - cleaned_fields = validate_jira_fields(fields) - except ValueError as exc: - audit_log( - "jira_search_rejected", - "jira_search", - success=False, - details={"reason": str(exc), **_session_jira_context()}, - ) - return make_error(f"Invalid fields: {exc}", status_code=400) - - # Normalise max_results: accept an int or a string-that-parses. Missing - # / invalid falls back to the client-side default (50, capped at 100). - effective_max: int | None = None - if max_results is not None: - try: - effective_max = max(1, min(int(max_results), 100)) - except TypeError, ValueError: - audit_log( - "jira_search_rejected", - "jira_search", - success=False, - details={ - "reason": "maxResults must be an integer", - **_session_jira_context(), - }, - ) - return make_error("maxResults must be an integer", status_code=400) - - try: - body = get_jira_client().search( - jql=jql, - fields=cleaned_fields or None, - next_page_token=next_page_token if isinstance(next_page_token, str) else None, - max_results=effective_max, - ) - except JiraCredentialsUnavailable as exc: - return _jira_not_configured_error(exc) - except JiraUpstreamError as exc: - audit_log( - "jira_search_upstream_error", - "jira_search", - success=False, - details={ - "upstream_status": exc.status_code, - **_session_jira_context(), - }, - ) - return _jira_error_from_upstream(exc) - - audit_log( - "jira_search", - "jira_search", - success=True, - details={ - "projects_extracted": sorted(scope.projects), - "jql_length": len(jql), - "max_results": effective_max, - "next_page_token_present": bool(next_page_token), - **_session_jira_context(), - }, - ) - return make_success("Jira search executed", body) - - -@app.route("/api/v1/jira/ticket/comments", methods=["POST"]) -@require_session_auth -@require_private_mode -def jira_ticket_comments() -> tuple[Response, int] | Response: - """Fetch comments for a Jira issue.""" - data = request.get_json(silent=True) or {} - ticket = data.get("ticket") - - if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): - audit_log( - "jira_ticket_comments_rejected", - "jira_ticket_comments", - success=False, - details={"reason": "invalid ticket shape", "ticket": ticket, **_session_jira_context()}, - ) - return make_error( - "Invalid ticket key (expected e.g. 'FOO-123')", - status_code=400, - details={"ticket": ticket}, - ) - - project = extract_project_key(ticket) - if not is_project_allowed(project): - return _project_not_allowlisted_response( - event="jira_ticket_comments_denied", - ticket=ticket, - project=project, - reason="project not allowlisted", - ) - - try: - body = get_jira_client().get_comments(ticket) - except JiraCredentialsUnavailable as exc: - return _jira_not_configured_error(exc) - except JiraUpstreamError as exc: - audit_log( - "jira_ticket_comments_upstream_error", - "jira_ticket_comments", - success=False, - details={ - "ticket": ticket, - "project": project, - "upstream_status": exc.status_code, - **_session_jira_context(), - }, - ) - return _jira_error_from_upstream(exc) - - audit_log( - "jira_ticket_comments", - "jira_ticket_comments", - success=True, - details={ - "ticket": ticket, - "project": project, - "not_found": body.get("status") == "not_found", - **_session_jira_context(), - }, - ) - return make_success("Jira ticket comments fetched", body) - - -@app.route("/api/v1/jira/ticket/remotelinks", methods=["POST"]) -# Issue #1557 reviewer_code v1 finding #1: same launcher-auth -# bypass as ``/api/v1/jira/ticket/get`` — the in-flight signal-b -# detection in ``orchestrator.jira_reassess.fetch_remote_links`` -# uses the launcher secret to read each child's remote-link list. -@require_session_or_launcher_auth -@require_private_mode -def jira_ticket_remotelinks() -> tuple[Response, int] | Response: - """Fetch the remote-link list for a Jira issue (issue #1557 slice-2). - - Request body:: - - {"ticket": "FOO-123"} - - Read-only — wraps the Atlassian ``GET /rest/api/3/issue/{key}/ - remotelink`` endpoint. Used by the orchestrator's reassess - sweep's in-flight classifier (decision-7 signal b) and the - sandbox ``jira ticket remotelinks `` CLI subcommand to - catch human-opened PRs that the orchestrator's reverse-index - doesn't track. Inherits the same project-allowlist boundary as - every other Jira route — ``JIRA_WRITE_VERBS_DENIED`` and - ``validate_jira_api_path`` keep the path GET-only. - """ - data = request.get_json(silent=True) or {} - ticket = data.get("ticket") - - if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): - audit_log( - "jira_ticket_remotelinks_rejected", - "jira_ticket_remotelinks", - success=False, - details={ - "reason": "invalid ticket shape", - "ticket": ticket, - **_session_jira_context(), - }, - ) - return make_error( - "Invalid ticket key (expected e.g. 'FOO-123')", - status_code=400, - details={"ticket": ticket}, - ) - - project = extract_project_key(ticket) - if not is_project_allowed(project): - return _project_not_allowlisted_response( - event="jira_ticket_remotelinks_denied", - ticket=ticket, - project=project, - reason="project not allowlisted", - ) - - try: - body = get_jira_client().get_remotelinks(ticket) - except JiraCredentialsUnavailable as exc: - return _jira_not_configured_error(exc) - except JiraUpstreamError as exc: - audit_log( - "jira_ticket_remotelinks_upstream_error", - "jira_ticket_remotelinks", - success=False, - details={ - "ticket": ticket, - "project": project, - "upstream_status": exc.status_code, - **_session_jira_context(), - }, - ) - return _jira_error_from_upstream(exc) - - audit_log( - "jira_ticket_remotelinks", - "jira_ticket_remotelinks", - success=True, - details={ - "ticket": ticket, - "project": project, - "not_found": body.get("status") == "not_found", - "remotelink_count": len(body.get("remotelinks") or []) - if isinstance(body.get("remotelinks"), list) - else 0, - **_session_jira_context(), - }, - ) - return make_success("Jira remote links fetched", body) - - -# Allowlist of transition names the orchestrator-only ``/transition`` -# route accepts (issue #1557 decision-15). Anything else is rejected -# with HTTP 400 — keeps the agent-facing surface (which denies -# transitions wholesale via ``JIRA_WRITE_VERBS_DENIED``) and the -# orchestrator-only escape hatch in agreement: only ``Won't Do`` / -# ``Won't Fix`` transitions are wired up today. -_TRANSITION_ALLOWLIST: frozenset[str] = frozenset( - {name.lower() for name in ("Won't Do", "Won't Fix", "Wontfix")} -) - - -def _verify_orchestrator_transition_auth() -> tuple[bool, str]: - """Verify the caller of ``/api/v1/jira/ticket/transition`` is the - orchestrator (issue #1557 task-2-6). - - Two-factor check: - 1. ``Authorization: Bearer `` must validate - against the gateway's launcher secret. Note: sandbox pods - ALSO mount the launcher secret (it backs the standard - session-creation flow), so the bearer alone does not - distinguish orchestrator from sandbox — the loopback / - in-cluster check plus NetworkPolicy on the gateway pod - provides that scoping. See ``docs/architecture/ - orchestrator.md`` § "Trust model" for the full discussion. - 2. The request must originate from a loopback / in-cluster - source. We accept any caller whose source IP equals the - orchestrator's gateway-side IP, the loopback addresses - (``127.0.0.1`` / ``::1``), or anything in the cluster pod - subnet. This is a coarse RFC1918 check — it excludes - external traffic but does not by itself distinguish - orchestrator pods from sandbox pods. Without NetworkPolicy - restricting ``/transition`` ingress to the orchestrator's - pod selector, the launcher secret is the only remaining - barrier between a compromised sandbox and this route. - - Returns ``(ok, reason)``. - """ - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): - return False, "missing_bearer_auth" - presented = auth_header[len("Bearer ") :] - try: - launcher_secret = get_launcher_secret() - except LauncherSecretNotConfiguredError: - return False, "launcher_secret_not_configured" - if not launcher_secret or not secrets.compare_digest(presented, launcher_secret): - return False, "bad_bearer_auth" - - # Loopback / in-cluster source check. ``request.remote_addr`` is - # the immediate peer; for in-cluster traffic this is the - # orchestrator pod IP. We accept anything from RFC1918 / IPv6 - # link-local / loopback so the orchestrator can reach us via any - # ingress-side path (k3s NodePort, direct service IP, …). Public - # IPs are rejected. - remote_addr = request.remote_addr or "" - if not _is_in_cluster_source(remote_addr): - return False, "source_not_in_cluster" - - return True, "" - - -def _is_in_cluster_source(remote_addr: str) -> bool: - """Return True if ``remote_addr`` is a loopback / RFC1918 address.""" - if not remote_addr: - return False - try: - import ipaddress - - ip = ipaddress.ip_address(remote_addr) - except ValueError: - return False - if ip.is_loopback: - return True - if ip.is_private: - return True - if ip.is_link_local: - return True - return False - - -@app.route("/api/v1/jira/ticket/transition", methods=["POST"]) -def jira_ticket_transition() -> tuple[Response, int] | Response: - """Transition a Jira issue (issue #1557 slice-2 task-2-6). - - **Orchestrator-only**. The agent-facing Jira surface continues to - deny transitions via ``JIRA_WRITE_VERBS_DENIED`` — this route - bypasses the agent path entirely. Auth is a two-factor check: - a launcher-secret bearer token AND a loopback / in-cluster - source IP. Transition names are restricted to the allowlist - (``Won't Do`` / ``Won't Fix``) — anything else returns 400. - - Request body:: - - {"ticket": "FOO-123", - "transition_name": "Won't Do", - "comment": "Consolidated into FOO-200"} - - Returns ``200 OK`` on success with the upstream status code in - the response body. Audit log entry covers caller IP, transition - name, ticket key, and outcome. - """ - ok, reason = _verify_orchestrator_transition_auth() - if not ok: - audit_log( - "jira_ticket_transition_unauthorized", - "jira_ticket_transition", - success=False, - details={ - "reason": reason, - "remote_addr": request.remote_addr, - }, - ) - return make_error( - "Unauthorized — orchestrator-only route", - status_code=401 if reason != "source_not_in_cluster" else 403, - details={"reason": reason}, - ) - - data = request.get_json(silent=True) or {} - ticket = data.get("ticket") - transition_name = data.get("transition_name") - comment_text = data.get("comment") - - if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): - audit_log( - "jira_ticket_transition_rejected", - "jira_ticket_transition", - success=False, - details={ - "reason": "invalid ticket shape", - "ticket": ticket, - }, - ) - return make_error( - "Invalid ticket key (expected e.g. 'FOO-123')", - status_code=400, - details={"ticket": ticket}, - ) - - if not isinstance(transition_name, str) or not transition_name.strip(): - return make_error( - "transition_name is required", - status_code=400, - details={"reason": "missing_transition_name"}, - ) - if transition_name.strip().lower() not in _TRANSITION_ALLOWLIST: - audit_log( - "jira_ticket_transition_denied", - "jira_ticket_transition", - success=False, - details={ - "reason": "transition_not_allowlisted", - "transition_name": transition_name, - "ticket": ticket, - }, - ) - return make_error( - f"transition_name {transition_name!r} is not on the allowlist", - status_code=400, - details={ - "reason": "transition_not_allowlisted", - "allowed": sorted(_TRANSITION_ALLOWLIST), - }, - ) - - project = extract_project_key(ticket) - if not is_project_allowed(project): - return _project_not_allowlisted_response( - event="jira_ticket_transition_denied", - ticket=ticket, - project=project, - reason="project not allowlisted", - ) - - comment_adf: dict[str, Any] | None = None - if isinstance(comment_text, str) and comment_text.strip(): - try: - from .jira_adf import wrap_text_as_adf - except ImportError: - # Issue #1557 tester v1 lint finding: ``jira_adf`` ships - # without a ``py.typed`` marker so mypy reports it as - # ``import-untyped``. The companion import at line 5849 - # already uses the dual-ignore; mirror it here. - from jira_adf import wrap_text_as_adf # type: ignore[no-redef, import-untyped] - comment_adf = wrap_text_as_adf(comment_text.strip()) - - try: - status_code, body = get_jira_client().transition_issue( - ticket, - transition_name=transition_name.strip(), - comment_adf=comment_adf, - ) - except JiraCredentialsUnavailable as exc: - return _jira_not_configured_error(exc) - except JiraUpstreamError as exc: - audit_log( - "jira_ticket_transition_upstream_error", - "jira_ticket_transition", - success=False, - details={ - "ticket": ticket, - "project": project, - "transition_name": transition_name, - "upstream_status": exc.status_code, - }, - ) - return _jira_error_from_upstream(exc) - - audit_log( - "jira_ticket_transition", - "jira_ticket_transition", - success=True, - details={ - "ticket": ticket, - "project": project, - "transition_name": transition_name, - "upstream_status": status_code, - "comment_attached": bool(comment_adf), - "remote_addr": request.remote_addr, - }, - ) - return make_success( - "Jira ticket transitioned", - {"upstream_status": status_code, "body": body}, - ) - - -# Stamp the private-mode marker manually on ``jira_ticket_transition``. -# This route is **orchestrator-only** and intentionally available -# regardless of the per-pipeline ``session_mode`` (public / private) -# — the orchestrator drives Jira transitions on behalf of an epic -# pipeline as a side-effect of the operator's HITL approval, not as a -# sandboxed-agent request, so the agent-facing private-mode gate does -# not apply. The route's actual access controls are: -# 1. ``Authorization: Bearer `` (launcher-secret only, -# stamped in every gateway container — including sandboxes — so it -# is a coarse credential, not a sandbox/orchestrator discriminator); -# 2. RFC1918 / loopback source IP (excludes external traffic); -# 3. NetworkPolicy on the gateway pod restricting ``/transition`` -# ingress to the orchestrator's pod selector (operator-owned); -# 4. ``transition_name`` allowlist (``Won't Do`` / ``Won't Fix``); -# 5. Project allowlist via ``is_project_allowed``. -# The route-enumeration regression test in -# ``gateway/tests/test_jira_routes.py`` reads ``PRIVATE_MODE_MARKER_ATTR`` -# to assert every ``/api/v1/jira/*`` view has been audited for mode -# enforcement. We stamp it here so the invariant continues to hold -# while explicitly documenting that this is the deliberate -# orchestrator-only escape hatch (issue #1557 decision-15 + task-2-6). -# See ``docs/architecture/orchestrator.md`` § "Trust model" for the -# full discussion of why the launcher-secret bearer is not itself a -# strictly-stronger constraint than session auth. -try: - from .mode_gate import PRIVATE_MODE_MARKER_ATTR as _PRIVATE_MODE_MARKER_ATTR # noqa: E402 -except ImportError: - from mode_gate import PRIVATE_MODE_MARKER_ATTR as _PRIVATE_MODE_MARKER_ATTR # type: ignore[no-redef] # noqa: E402, I001 - -setattr(jira_ticket_transition, _PRIVATE_MODE_MARKER_ATTR, True) - - -@app.route("/api/v1/jira/execute", methods=["POST"]) -@require_session_auth -@require_private_mode -def jira_execute() -> tuple[Response, int] | Response: - """Generic read-only passthrough for whitelisted Jira REST paths. - - Request body:: - - {"method": "GET", - "path": "issue/FOO-123", - "query": {"fields": "summary"}, - "body": null} - - Only methods + paths accepted by ``validate_jira_api_path`` are allowed. - Write verbs (DELETE/PUT/PATCH) and path fragments listed in - ``JIRA_WRITE_VERBS_DENIED`` are refused unconditionally. - """ - data = request.get_json(silent=True) or {} - method = data.get("method") or "GET" - path = data.get("path") - query = data.get("query") - req_body = data.get("body") - - if not isinstance(path, str) or not path: - audit_log( - "jira_execute_rejected", - "jira_execute", - success=False, - details={"reason": "path required", **_session_jira_context()}, - ) - return make_error("path is required", status_code=400) - - if not isinstance(method, str): - audit_log( - "jira_execute_rejected", - "jira_execute", - success=False, - details={"reason": "method must be a string", **_session_jira_context()}, - ) - return make_error("method must be a string", status_code=400) - - method_upper = method.upper() - ok, reason = validate_jira_api_path(path, method_upper) - if not ok: - audit_log( - "jira_execute_denied", - "jira_execute", - success=False, - details={ - "method": method_upper, - "path": path, - "reason": reason, - **_session_jira_context(), - }, - ) - return make_error( - f"Jira API call rejected: {reason}", - status_code=403, - details={"method": method_upper, "path": path, "reason": reason}, - ) - - # Path is structurally OK — extract project key (if any) and allowlist it. - # The accepted shapes are ``issue/[/comment]`` and - # ``project/``. Both carry a project key inline that is checked - # against the allowlist. Bare ``project`` is excluded (would leak all - # projects visible to the API token). - stripped = path.strip("/").split("?", 1)[0] - ticket: str | None = None - project: str | None = None - head = stripped.split("/") - if head and head[0] == "issue" and len(head) >= 2: - ticket = head[1] - project = extract_project_key(ticket) - elif head and head[0] == "project" and len(head) >= 2: - project = head[1] - - if project is not None and not is_project_allowed(project): - return _project_not_allowlisted_response( - event="jira_execute_denied", - ticket=ticket, - project=project, - reason="project not allowlisted", - extra={"method": method_upper, "path": path}, - ) - - # Normalise query & body — they must be dicts or None. - if query is not None and not isinstance(query, dict): - return make_error("query must be an object", status_code=400) - if req_body is not None and not isinstance(req_body, dict): - return make_error("body must be an object", status_code=400) - - try: - body = get_jira_client().execute_raw( - method=method_upper, - path=stripped, - query=query, - body=req_body, - ) - except JiraCredentialsUnavailable as exc: - return _jira_not_configured_error(exc) - except JiraUpstreamError as exc: - audit_log( - "jira_execute_upstream_error", - "jira_execute", - success=False, - details={ - "method": method_upper, - "path": stripped, - "upstream_status": exc.status_code, - **_session_jira_context(), - }, - ) - return _jira_error_from_upstream(exc) - - audit_log( - "jira_execute", - "jira_execute", - success=True, - details={ - "method": method_upper, - "path": stripped, - "project": project, - "ticket": ticket, - **_session_jira_context(), - }, - ) - return make_success("Jira API call executed", body) - - -# ----------------------------------------------------------------------------- -# Jira write verbs (issue #1924) -# ----------------------------------------------------------------------------- -# -# Each route validates a tight per-verb body schema before calling the -# matching ``JiraClient`` write method. Body content (``description``, -# ``comment.body``) is **never** logged to the audit trail — only structural -# metadata (which fields were present, content lengths, label *values*). - -# Atlassian's documented summary cap. -_JIRA_SUMMARY_MAX_CHARS: int = 255 - -# Description / comment body cap (refine feedback Q2): 32 KiB. Atlassian -# itself accepts larger but the gateway shouldn't proxy multi-MB bodies. -_JIRA_BODY_MAX_CHARS: int = 32 * 1024 - -# Labels: max 30 entries, each up to 50 chars. -_JIRA_LABELS_MAX_COUNT: int = 30 -_JIRA_LABEL_MAX_CHARS: int = 50 - -# Allowlisted top-level keys for write bodies. Anything outside this set is -# rejected as either custom-field smuggling, HTTP-method tunnelling, or a -# typo. Keep this surface tight on purpose. -_JIRA_CREATE_ALLOWED_KEYS: frozenset[str] = frozenset( - { - "project", - "issuetype", - "summary", - "description", - "labels", - "parent", - "epicLink", - "idempotencyKey", - } -) -_JIRA_EDIT_ALLOWED_KEYS: frozenset[str] = frozenset( - { - "ticket", - "summary", - "description", - "labels", - "addLabels", - "removeLabels", - "notifyUsers", - } -) -_JIRA_COMMENT_ALLOWED_KEYS: frozenset[str] = frozenset( - { - "ticket", - "body", - "idempotencyKey", - } -) -_JIRA_LINK_ALLOWED_KEYS: frozenset[str] = frozenset( - { - "type", - "inwardIssue", - "outwardIssue", - "comment", - "idempotencyKey", - } -) - -# Atlassian-known issuetype names accepted in create requests (refine -# decision-8: both name and numeric ID are accepted). Names outside this -# tight set are rejected to keep operator surface predictable. -_JIRA_ALLOWED_ISSUETYPE_NAMES: frozenset[str] = frozenset( - {"Task", "Story", "Bug", "Epic", "Sub-task", "Subtask"} -) - - -def _jira_write_audit_meta(body: dict[str, Any]) -> dict[str, Any]: - """Return structural metadata for a write-verb audit record. - - Logs **field names changed**, **content lengths**, **label values**, and - **link-type names** (refine feedback Q5) — never raw body content. - """ - meta: dict[str, Any] = {} - fields_present: list[str] = [] - for key in ( - "summary", - "description", - "labels", - "addLabels", - "removeLabels", - "parent", - "epicLink", - "issuetype", - "project", - "ticket", - "body", - "comment", - "type", - "inwardIssue", - "outwardIssue", - ): - if key in body: - fields_present.append(key) - if fields_present: - meta["fields_present"] = fields_present - - summary = body.get("summary") - if isinstance(summary, str): - meta["summary_length"] = len(summary) - - description = body.get("description") - if isinstance(description, str): - meta["description_length"] = len(description) - elif isinstance(description, dict): - meta["description_length"] = -1 # ADF passthrough; length unknown - meta["description_kind"] = "adf" - - comment_body = body.get("body") - if isinstance(comment_body, str): - meta["body_length"] = len(comment_body) - elif isinstance(comment_body, dict): - meta["body_length"] = -1 - meta["body_kind"] = "adf" - - labels = body.get("labels") - if isinstance(labels, list): - meta["labels"] = [v for v in labels if isinstance(v, str)] - add_labels = body.get("addLabels") - if isinstance(add_labels, list): - meta["add_labels"] = [v for v in add_labels if isinstance(v, str)] - remove_labels = body.get("removeLabels") - if isinstance(remove_labels, list): - meta["remove_labels"] = [v for v in remove_labels if isinstance(v, str)] - - link_type = body.get("type") - if isinstance(link_type, str): - meta["link_type"] = link_type - - issuetype = body.get("issuetype") - if isinstance(issuetype, dict): - if isinstance(issuetype.get("name"), str): - meta["issuetype_name"] = issuetype["name"] - if isinstance(issuetype.get("id"), str): - meta["issuetype_id"] = issuetype["id"] - elif isinstance(issuetype, str): - meta["issuetype_name"] = issuetype - - return meta - - -def _validate_jira_write_keys( - body: dict[str, Any], allowed: frozenset[str], operation: str -) -> tuple[Response, int] | None: - """Reject unknown / suspect top-level body keys. - - Returns a 400 response when an unknown key is found (custom-field - smuggling, ``method``-tunnel attempts, or typos), otherwise ``None``. - """ - extras = sorted(set(body) - allowed) - if not extras: - return None - audit_log( - f"{operation}_rejected", - operation, - success=False, - details={ - "reason": "unknown_body_keys", - "unknown_keys": extras, - **_session_jira_context(), - }, - ) - return make_error( - f"Unknown body keys: {extras}", - status_code=400, - details={"unknown_keys": extras}, - ) - - -def _validate_jira_text_field( - value: Any, - *, - field: str, - max_chars: int, - allow_adf: bool = False, -) -> tuple[str | dict[str, Any] | None, tuple[Response, int] | None]: - """Validate a string-or-ADF text field. - - Returns ``(cleaned_value, None)`` on success or - ``(None, error_response)`` on failure. ``None`` is treated as "not - supplied"; callers handle the optional vs required distinction. - """ - if value is None: - return None, None - - if allow_adf and isinstance(value, dict): - # ADF dict — ensure it's structurally valid; size cap applied to - # serialised length so a malicious nested ADF tree can't hide. - try: - from .jira_adf import is_adf_dict - except ImportError: - from jira_adf import is_adf_dict # type: ignore[no-redef] - if not is_adf_dict(value): - return None, make_error( - f"{field} must be a string or a valid ADF document", - status_code=400, - ) - # Size check via serialised length as a proxy. - serialised = json.dumps(value) - if len(serialised) > max_chars: - return None, make_error( - f"{field} exceeds maximum length ({max_chars} chars)", - status_code=400, - ) - return value, None - - if not isinstance(value, str): - return None, make_error(f"{field} must be a string", status_code=400) - if len(value) > max_chars: - return None, make_error( - f"{field} exceeds maximum length ({max_chars} chars)", - status_code=400, - ) - return value, None - - -def _validate_jira_labels( - value: Any, *, field: str -) -> tuple[list[str] | None, tuple[Response, int] | None]: - """Validate a labels list (count cap + per-entry length cap).""" - if value is None: - return None, None - if not isinstance(value, list): - return None, make_error(f"{field} must be a list", status_code=400) - if len(value) > _JIRA_LABELS_MAX_COUNT: - return None, make_error( - f"{field} exceeds maximum of {_JIRA_LABELS_MAX_COUNT} entries", - status_code=400, - ) - cleaned: list[str] = [] - for entry in value: - if not isinstance(entry, str): - return None, make_error(f"{field} entries must be strings", status_code=400) - if not entry: - return None, make_error(f"{field} entries must be non-empty", status_code=400) - if len(entry) > _JIRA_LABEL_MAX_CHARS: - return None, make_error( - f"{field} entry exceeds maximum length ({_JIRA_LABEL_MAX_CHARS} chars)", - status_code=400, - ) - if " " in entry: - return None, make_error( - f"{field} entries must not contain whitespace", - status_code=400, - ) - cleaned.append(entry) - return cleaned, None - - -@app.route("/api/v1/jira/ticket/create", methods=["POST"]) -@require_session_auth -@require_private_mode -def jira_ticket_create() -> tuple[Response, int] | Response: - """Create a Jira issue via ``POST /rest/api/3/issue``. - - Request body:: - - {"project": "ENG", - "issuetype": "Task" | {"name": "Task"} | {"id": "10001"}, - "summary": "...", - "description": "..." | | null, - "labels": ["foo", "bar"], - "parent": "ENG-1" | null, - "epicLink": "ENG-2" | null, - "idempotencyKey": "..." | null} - - ``parent`` and ``epicLink`` are mutually exclusive. Cross-project - parents are rejected (refine decision-17). ``epicLink`` dispatches via - ``JiraPolicy.epic_link_field`` (``parent`` or ``customfield_10014``). - """ - operation = "jira_ticket_create" - data = request.get_json(silent=True) or {} - - if not isinstance(data, dict): - return make_error("body must be a JSON object", status_code=400) - - err = _validate_jira_write_keys(data, _JIRA_CREATE_ALLOWED_KEYS, operation) - if err is not None: - return err - - project = data.get("project") - issuetype = data.get("issuetype") - summary = data.get("summary") - description = data.get("description") - labels = data.get("labels") - parent = data.get("parent") - epic_link = data.get("epicLink") - idempotency_key = data.get("idempotencyKey") - - if not isinstance(project, str) or not _JIRA_PROJECT_KEY_RE.fullmatch(project): - audit_log( - f"{operation}_rejected", - operation, - success=False, - details={"reason": "invalid project shape", **_session_jira_context()}, - ) - return make_error("Invalid project key", status_code=400) - - if not is_project_allowed(project): - return _project_not_allowlisted_response( - event=f"{operation}_denied", - ticket=None, - project=project, - reason="project not allowlisted", - ) - - # issuetype: name or numeric id (refine decision-8). - if isinstance(issuetype, str): - if issuetype not in _JIRA_ALLOWED_ISSUETYPE_NAMES: - audit_log( - f"{operation}_rejected", - operation, - success=False, - details={ - "reason": "unknown issuetype", - "issuetype": issuetype, - **_session_jira_context(), - }, - ) - return make_error( - f"Unknown issuetype name: {issuetype!r}", - status_code=400, - ) - issuetype_arg: dict[str, Any] | str = issuetype - elif isinstance(issuetype, dict): - if "name" in issuetype: - name = issuetype["name"] - if not isinstance(name, str) or name not in _JIRA_ALLOWED_ISSUETYPE_NAMES: - audit_log( - f"{operation}_rejected", - operation, - success=False, - details={"reason": "unknown issuetype name", **_session_jira_context()}, - ) - return make_error(f"Unknown issuetype name: {name!r}", status_code=400) - issuetype_arg = {"name": name} - elif "id" in issuetype: - type_id = issuetype["id"] - if not isinstance(type_id, str) or not type_id.isdigit(): - return make_error("issuetype.id must be a numeric string", status_code=400) - issuetype_arg = {"id": type_id} - else: - return make_error("issuetype must include name or id", status_code=400) - else: - return make_error( - "issuetype must be a string, or a dict with 'name' or 'id'", - status_code=400, - ) - - if not isinstance(summary, str) or not summary.strip(): - return make_error("summary is required", status_code=400) - if len(summary) > _JIRA_SUMMARY_MAX_CHARS: - return make_error( - f"summary exceeds maximum length ({_JIRA_SUMMARY_MAX_CHARS} chars)", - status_code=400, - ) - - cleaned_description, err = _validate_jira_text_field( - description, field="description", max_chars=_JIRA_BODY_MAX_CHARS, allow_adf=True - ) - if err is not None: - return err - - cleaned_labels, err = _validate_jira_labels(labels, field="labels") - if err is not None: - return err - - if parent is not None and epic_link is not None: - audit_log( - f"{operation}_rejected", - operation, - success=False, - details={"reason": "parent_and_epic_link", **_session_jira_context()}, - ) - return make_error( - "parent and epicLink are mutually exclusive", - status_code=400, - ) - - if parent is not None: - if not isinstance(parent, str) or not _JIRA_TICKET_KEY_RE.fullmatch(parent): - return make_error("Invalid parent ticket key", status_code=400) - # Cross-project parent rejection (refine decision-17). - parent_project = extract_project_key(parent) - if parent_project != project: - audit_log( - f"{operation}_rejected", - operation, - success=False, - details={ - "reason": "cross_project_parent", - "project": project, - "parent_project": parent_project, - **_session_jira_context(), - }, - ) - return make_error( - "parent.key project must match the new ticket's project", - status_code=400, - details={"project": project, "parent_project": parent_project}, - ) - - if epic_link is not None: - if not isinstance(epic_link, str) or not _JIRA_TICKET_KEY_RE.fullmatch(epic_link): - return make_error("Invalid epicLink ticket key", status_code=400) - # epicLink writes to the same Atlassian field as `parent` when the - # site uses next-gen / company-managed projects (default - # `epic_link_field == "parent"`). That makes `epicLink` a literal - # alias for `parent` at the wire level, so it MUST inherit the same - # allowlist + cross-project policy as `parent` (decision-9, decision-17). - # Otherwise an agent in an allowlisted project could parent a new - # ticket under an epic in a non-allowlisted project just by routing - # through the `epicLink` shorthand instead of `parent`. - epic_project = extract_project_key(epic_link) - if not is_project_allowed(epic_project): - return _project_not_allowlisted_response( - event=f"{operation}_denied", - ticket=epic_link, - project=epic_project, - reason="epicLink project not allowlisted", - ) - if epic_project != project: - audit_log( - f"{operation}_rejected", - operation, - success=False, - details={ - "reason": "cross_project_epic_link", - "project": project, - "epic_project": epic_project, - **_session_jira_context(), - }, - ) - return make_error( - "epicLink project must match the new ticket's project", - status_code=400, - details={"project": project, "epic_project": epic_project}, - ) - - if idempotency_key is not None and not isinstance(idempotency_key, str): - return make_error("idempotencyKey must be a string", status_code=400) - - try: - status_code, body_json, cache_hit = get_jira_client().create_issue( - project_key=project, - issuetype=issuetype_arg, - summary=summary, - description=cleaned_description, - labels=cleaned_labels, - parent=parent, - epic_link=epic_link, - epic_link_field=jira_epic_link_field(), - idempotency_key=idempotency_key if isinstance(idempotency_key, str) else None, - ) - except JiraCredentialsUnavailable as exc: - return _jira_not_configured_error(exc) - except JiraUpstreamError as exc: - audit_log( - f"{operation}_upstream_error", - operation, - success=False, - details={ - "project": project, - "upstream_status": exc.status_code, - **_jira_write_audit_meta(data), - **_session_jira_context(), - }, - ) - return _jira_error_from_upstream(exc) - - new_key = body_json.get("key") if isinstance(body_json, dict) else None - new_id = body_json.get("id") if isinstance(body_json, dict) else None - self_url = body_json.get("self") if isinstance(body_json, dict) else None - browse_url: str | None = None - if isinstance(self_url, str) and "/rest/api/" in self_url and isinstance(new_key, str): - # Trim the trailing /rest/api/3/issue/ to recover the site root, - # then append /browse/. This mirrors what Atlassian shows in - # its UI links. - site = self_url.split("/rest/api/", 1)[0] - browse_url = f"{site}/browse/{new_key}" - - # Match the doc's audit grammar: rejection events use ``_rejected`` / - # ``_denied`` / ``_upstream_error`` suffixes, so successful writes use - # ``_ok`` (reviewer_code_holistic cycle 1 finding #3, #1924). - audit_log( - f"{operation}_ok", - operation, - success=True, - details={ - "project": project, - "ticket": new_key, - "upstream_status": status_code, - "idempotency_key_present": bool(idempotency_key), - "idempotency_hit": cache_hit, - **_jira_write_audit_meta(data), - **_session_jira_context(), - }, - ) - - envelope: dict[str, Any] = { - "status": "created", - "key": new_key, - "id": new_id, - "browse_url": browse_url, - } - return make_success("Jira ticket created", envelope) - - -@app.route("/api/v1/jira/ticket/edit", methods=["POST"]) -@require_session_auth -@require_private_mode -def jira_ticket_edit() -> tuple[Response, int] | Response: - """Edit a Jira issue via ``PUT /rest/api/3/issue/{key}``. - - Request body:: - - {"ticket": "ENG-1", - "summary": "..." | null, - "description": "..." | | null, - "labels": [...] | null, # replace mode - "addLabels": [...] | null, # incremental mode - "removeLabels": [...] | null, - "notifyUsers": false | true} # default: false - - Replace-mode (``labels``) and incremental-mode - (``addLabels``/``removeLabels``) are mutually exclusive. - """ - operation = "jira_ticket_edit" - data = request.get_json(silent=True) or {} - - if not isinstance(data, dict): - return make_error("body must be a JSON object", status_code=400) - - err = _validate_jira_write_keys(data, _JIRA_EDIT_ALLOWED_KEYS, operation) - if err is not None: - return err - - ticket = data.get("ticket") - if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): - audit_log( - f"{operation}_rejected", - operation, - success=False, - details={"reason": "invalid ticket shape", **_session_jira_context()}, - ) - return make_error("Invalid ticket key", status_code=400) - - project = extract_project_key(ticket) - if not is_project_allowed(project): - return _project_not_allowlisted_response( - event=f"{operation}_denied", - ticket=ticket, - project=project, - reason="project not allowlisted", - ) - - summary = data.get("summary") - description = data.get("description") - labels = data.get("labels") - add_labels = data.get("addLabels") - remove_labels = data.get("removeLabels") - notify_users = data.get("notifyUsers", False) - - if summary is not None: - if not isinstance(summary, str): - return make_error("summary must be a string", status_code=400) - if len(summary) > _JIRA_SUMMARY_MAX_CHARS: - return make_error( - f"summary exceeds maximum length ({_JIRA_SUMMARY_MAX_CHARS} chars)", - status_code=400, - ) - - cleaned_description, err = _validate_jira_text_field( - description, field="description", max_chars=_JIRA_BODY_MAX_CHARS, allow_adf=True - ) - if err is not None: - return err - - has_replace = labels is not None - has_incremental = (add_labels is not None) or (remove_labels is not None) - if has_replace and has_incremental: - audit_log( - f"{operation}_rejected", - operation, - success=False, - details={"reason": "mixed_label_modes", **_session_jira_context()}, - ) - return make_error( - "labels and addLabels/removeLabels are mutually exclusive", - status_code=400, - ) - - cleaned_labels, err = _validate_jira_labels(labels, field="labels") - if err is not None: - return err - cleaned_add, err = _validate_jira_labels(add_labels, field="addLabels") - if err is not None: - return err - cleaned_remove, err = _validate_jira_labels(remove_labels, field="removeLabels") - if err is not None: - return err - - if not isinstance(notify_users, bool): - return make_error("notifyUsers must be a boolean", status_code=400) - - # Require at least one mutating field to avoid no-op edits hitting upstream. - if ( - summary is None - and cleaned_description is None - and cleaned_labels is None - and cleaned_add is None - and cleaned_remove is None - ): - return make_error( - "edit requires at least one of summary/description/labels/addLabels/removeLabels", - status_code=400, - ) - - try: - get_jira_client().edit_issue( - key=ticket, - summary=summary, - description=cleaned_description, - labels=cleaned_labels, - add_labels=cleaned_add, - remove_labels=cleaned_remove, - notify_users=notify_users, - ) - except ValueError as exc: - # Defence in depth — the route already rejected mixed modes. - return make_error(str(exc), status_code=400) - except JiraCredentialsUnavailable as exc: - return _jira_not_configured_error(exc) - except JiraUpstreamError as exc: - audit_log( - f"{operation}_upstream_error", - operation, - success=False, - details={ - "ticket": ticket, - "project": project, - "upstream_status": exc.status_code, - **_jira_write_audit_meta(data), - **_session_jira_context(), - }, - ) - return _jira_error_from_upstream(exc) - - audit_log( - f"{operation}_ok", - operation, - success=True, - details={ - "ticket": ticket, - "project": project, - "notify_users": notify_users, - # editIssue does not consult the idempotency cache (Atlassian - # PUT is naturally idempotent), but the field is included here - # for grammar parity with the create / comment / link routes. - "idempotency_key_present": False, - "idempotency_hit": False, - **_jira_write_audit_meta(data), - **_session_jira_context(), - }, - ) - return make_success("Jira ticket updated", {"status": "updated", "key": ticket}) - - -@app.route("/api/v1/jira/ticket/comment/add", methods=["POST"]) -@require_session_auth -@require_private_mode -def jira_ticket_comment_add() -> tuple[Response, int] | Response: - """Add a comment to a Jira issue. - - Request body:: - - {"ticket": "ENG-1", - "body": "..." | , - "idempotencyKey": "..." | null} - - Visibility (role/group restriction) is rejected — v1 does not expose - that knob (refine decision-6). Body content is **never** logged. - """ - operation = "jira_ticket_comment_add" - data = request.get_json(silent=True) or {} - - if not isinstance(data, dict): - return make_error("body must be a JSON object", status_code=400) - - if "visibility" in data: - return make_error( - "comment visibility is not supported in v1", - status_code=400, - ) - - err = _validate_jira_write_keys(data, _JIRA_COMMENT_ALLOWED_KEYS, operation) - if err is not None: - return err - - ticket = data.get("ticket") - body = data.get("body") - idempotency_key = data.get("idempotencyKey") - - if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): - audit_log( - f"{operation}_rejected", - operation, - success=False, - details={"reason": "invalid ticket shape", **_session_jira_context()}, - ) - return make_error("Invalid ticket key", status_code=400) - - project = extract_project_key(ticket) - if not is_project_allowed(project): - return _project_not_allowlisted_response( - event=f"{operation}_denied", - ticket=ticket, - project=project, - reason="project not allowlisted", - ) - - cleaned_body, err = _validate_jira_text_field( - body, field="body", max_chars=_JIRA_BODY_MAX_CHARS, allow_adf=True - ) - if err is not None: - return err - if cleaned_body is None: - return make_error("body is required", status_code=400) - - if idempotency_key is not None and not isinstance(idempotency_key, str): - return make_error("idempotencyKey must be a string", status_code=400) - - try: - _status, comment_json, cache_hit = get_jira_client().add_comment( - key=ticket, - body=cleaned_body, - idempotency_key=idempotency_key if isinstance(idempotency_key, str) else None, - ) - except JiraCredentialsUnavailable as exc: - return _jira_not_configured_error(exc) - except JiraUpstreamError as exc: - audit_log( - f"{operation}_upstream_error", - operation, - success=False, - details={ - "ticket": ticket, - "project": project, - "upstream_status": exc.status_code, - # Note: _jira_write_audit_meta intentionally avoids body content; - # we still record body_length / body_kind here. - **_jira_write_audit_meta(data), - **_session_jira_context(), - }, - ) - return _jira_error_from_upstream(exc) - - audit_log( - f"{operation}_ok", - operation, - success=True, - details={ - "ticket": ticket, - "project": project, - "idempotency_key_present": bool(idempotency_key), - "idempotency_hit": cache_hit, - **_jira_write_audit_meta(data), - **_session_jira_context(), - }, - ) - return make_success("Jira comment added", comment_json) - - -@app.route("/api/v1/jira/issue-link/create", methods=["POST"]) -@require_session_auth -@require_private_mode -def jira_issue_link_create() -> tuple[Response, int] | Response: - """Create an issue link between two tickets. - - Request body:: - - {"type": "Blocks", - "inwardIssue": "ENG-1", - "outwardIssue": "ENG-2", - "comment": "..." | | null, - "idempotencyKey": "..." | null} - - Both tickets' projects must be in the allowlist (refine decision-9). - Atlassian does **not** dedupe identical triples, so the gateway uses - its idempotency cache (decision-28) when ``idempotencyKey`` is set. - """ - operation = "jira_issue_link_create" - data = request.get_json(silent=True) or {} - - if not isinstance(data, dict): - return make_error("body must be a JSON object", status_code=400) - - err = _validate_jira_write_keys(data, _JIRA_LINK_ALLOWED_KEYS, operation) - if err is not None: - return err - - link_type = data.get("type") - inward = data.get("inwardIssue") - outward = data.get("outwardIssue") - comment = data.get("comment") - idempotency_key = data.get("idempotencyKey") - - if not isinstance(link_type, str) or not link_type: - return make_error("type is required", status_code=400) - if not jira_link_type_allowed(link_type): - audit_log( - f"{operation}_rejected", - operation, - success=False, - details={ - "reason": "link_type_not_allowlisted", - "link_type": link_type, - **_session_jira_context(), - }, - ) - return make_error( - f"Link type {link_type!r} not in allowlist", - status_code=400, - details={"link_type": link_type}, - ) - - if not isinstance(inward, str) or not _JIRA_TICKET_KEY_RE.fullmatch(inward): - return make_error("inwardIssue must be a Jira ticket key", status_code=400) - if not isinstance(outward, str) or not _JIRA_TICKET_KEY_RE.fullmatch(outward): - return make_error("outwardIssue must be a Jira ticket key", status_code=400) - - inward_project = extract_project_key(inward) - outward_project = extract_project_key(outward) - for proj, ticket in ((inward_project, inward), (outward_project, outward)): - if not is_project_allowed(proj): - return _project_not_allowlisted_response( - event=f"{operation}_denied", - ticket=ticket, - project=proj, - reason="project not allowlisted", - ) - - cleaned_comment, err = _validate_jira_text_field( - comment, field="comment", max_chars=_JIRA_BODY_MAX_CHARS, allow_adf=True - ) - if err is not None: - return err - - if idempotency_key is not None and not isinstance(idempotency_key, str): - return make_error("idempotencyKey must be a string", status_code=400) - - try: - _status, _link_json, cache_hit = get_jira_client().create_issue_link( - link_type=link_type, - inward_key=inward, - outward_key=outward, - comment=cleaned_comment, - idempotency_key=idempotency_key if isinstance(idempotency_key, str) else None, - ) - except JiraCredentialsUnavailable as exc: - return _jira_not_configured_error(exc) - except JiraUpstreamError as exc: - audit_log( - f"{operation}_upstream_error", - operation, - success=False, - details={ - "inwardIssue": inward, - "outwardIssue": outward, - "type": link_type, - "upstream_status": exc.status_code, - **_jira_write_audit_meta(data), - **_session_jira_context(), - }, - ) - return _jira_error_from_upstream(exc) - - audit_log( - f"{operation}_ok", - operation, - success=True, - details={ - "inwardIssue": inward, - "outwardIssue": outward, - "type": link_type, - "inward_project": inward_project, - "outward_project": outward_project, - "idempotency_key_present": bool(idempotency_key), - "idempotency_hit": cache_hit, - **_jira_write_audit_meta(data), - **_session_jira_context(), - }, - ) - return make_success( - "Jira issue link created", - { - "status": "created", - "inwardIssue": inward, - "outwardIssue": outward, - "type": link_type, - }, - ) - - -# ============================================================================= -# Confluence REST Endpoints -# ============================================================================= -# -# Read-only wrappers around Atlassian Cloud Confluence's REST API. Routes -# live on the ``/api/v1/confluence/*`` prefix and mirror the shape of -# ``/api/v1/jira/*``: session auth, private-mode gate, space allowlist, -# structured audit log. -# -# Credentials come from ``gateway/confluence_credentials.py`` (loaded from -# the same ``secrets.env`` file as Jira and GitHub) and are never exported -# to the sandbox. See: -# - gateway/confluence_client.py — client + path allowlist + redaction -# - gateway/confluence_policy.py — space allowlist loader -# - gateway/confluence_search.py — CQL space-scope extractor -# - gateway/mode_gate.py — @require_private_mode decorator - -# Numeric Confluence page id / space id shape. -_CONFLUENCE_PAGE_ID_RE = re.compile(r"^\d+$") -_CONFLUENCE_SPACE_KEY_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$") - - -def _session_confluence_context() -> dict[str, Any]: - """Return session-scoped fields to include in Confluence audit records. - - Per refine decision 13 there is no per-session ``session.confluence_*`` - field — pageId / spaceKey are recovered from the request body or - response per call. - """ - ctx: dict[str, Any] = { - "session_mode": getattr(g, "session_mode", None), - } - session = getattr(g, "session", None) - if session is not None: - ctx["pipeline_id"] = getattr(session, "pipeline_id", None) - ctx["agent_role"] = getattr(session, "agent_role", None) - return ctx - - -def _confluence_error_from_upstream(exc: ConfluenceUpstreamError) -> tuple[Response, int]: - """Translate a ``ConfluenceUpstreamError`` to an HTTP response. - - Atlassian error envelopes occasionally include user-identifying strings - (e.g. account ids embedded in messages) and space-enumeration leaks - (e.g. ``"valid keys are: ENG, DOCS, SECRET"``). The success-path - redactor only runs on 2xx bodies, so we apply it here too before the - upstream body crosses the gateway/sandbox boundary. - """ - if 300 <= exc.status_code < 400: - # A 3xx is never a valid read response from the Atlassian REST API — - # it's the signature of an unauthenticated/misrouted request being - # bounced to the login page. The usual cause is a missing/invalid - # gateway Atlassian token or a wrong base URL (e.g. ATLASSIAN_BASE_URL - # set to a page browser URL, or CONFLUENCE_BASE_URL missing the - # ``/wiki`` suffix). Surface that pointedly instead of an opaque 502 so - # operators don't have to reverse-engineer the redirect. Still 502 - # (bad upstream response), distinct from the 503 "creds absent" path. - message = ( - f"Confluence upstream returned {exc.status_code} (redirect) — the " - "gateway received a login redirect instead of a REST response. " - "This usually means the gateway's Atlassian credentials are " - "missing/invalid or the base URL is wrong (e.g. ATLASSIAN_BASE_URL " - "must be the bare tenant origin, or CONFLUENCE_BASE_URL must include " - "the /wiki suffix)." - ) - details: dict[str, Any] = { - "upstream_status": exc.status_code, - "upstream_body": _redact_upstream_error_body(exc.body), - "path": exc.path, - "likely_cause": "missing_or_invalid_atlassian_credentials_or_base_url", - } - # Surface the upstream ``Location`` when present so the operator can - # confirm the bounce target (typically ``/login`` or the tenant root) - # without reproducing. - if exc.location: - message += f" Upstream redirected to: {exc.location}" - details["upstream_location"] = exc.location - return make_error(message, status_code=502, details=details) - if 400 <= exc.status_code < 500: - status = exc.status_code - else: - status = 502 - return make_error( - f"Confluence upstream error {exc.status_code}", - status_code=status, - details={ - "upstream_status": exc.status_code, - "upstream_body": _redact_upstream_error_body(exc.body), - "path": exc.path, - }, - ) - - -def _redact_upstream_error_body(body: Any) -> Any: - """Run ``redact_response`` over an Atlassian error envelope. - - Atlassian returns errors as JSON dicts (and very occasionally as plain - text); the redactor mutates dicts/lists in place. Non-container shapes - pass through unchanged. - """ - if isinstance(body, (dict, list)): - return redact_response(body) - return body - - -def _confluence_not_configured_error( - exc: ConfluenceCredentialsUnavailable, -) -> tuple[Response, int]: - """Translate missing credentials to an HTTP 503 response.""" - return make_error( - "Confluence credentials not configured on the gateway", - status_code=503, - details={"reason": str(exc)}, - ) - - -def _confluence_response_too_large( - exc: ConfluenceResponseTooLarge, - *, - page_id: str | None = None, - space_key: str | None = None, -) -> tuple[Response, int]: - """Translate an oversized response to HTTP 413.""" - details: dict[str, Any] = {"size_bytes": exc.size_bytes, "path": exc.path} - if page_id is not None: - details["pageId"] = page_id - if space_key is not None: - details["spaceKey"] = space_key - return make_error( - "Confluence response too large", - status_code=413, - details=details, - ) - - -def _confluence_forbidden_response( - exc: ConfluenceUpstreamForbidden, - *, - event: str, - page_id: str | None = None, - space_key: str | None = None, -) -> tuple[Response, int]: - """Translate an upstream 403 into HTTP 403 with the dedicated audit event.""" - details: dict[str, Any] = { - "upstream_status": 403, - "reason": "bot_account_lacks_read_access", - "path": exc.path, - **_session_confluence_context(), - } - if page_id is not None: - details["pageId"] = page_id - if space_key is not None: - details["spaceKey"] = space_key - audit_log(event, event, success=False, details=details) - body: dict[str, Any] = { - "status": "forbidden", - "reason": "bot_account_lacks_read_access", - } - if page_id is not None: - body["pageId"] = page_id - if space_key is not None: - body["spaceKey"] = space_key - return make_error( - "Confluence upstream forbidden", - status_code=403, - details=body, - ) - - -def _confluence_space_denied_response( - *, - event: str, - page_id: str | None, - space_key: str | None, - reason: str, - extra: dict[str, Any] | None = None, -) -> tuple[Response, int]: - """Emit a structured audit record and return the canonical 403.""" - details: dict[str, Any] = {"spaceKey": space_key, "reason": reason} - if page_id is not None: - details["pageId"] = page_id - if extra: - details.update(extra) - details.update(_session_confluence_context()) - audit_log(event, event, success=False, details=details) - return make_error( - "Confluence space not allowlisted", - status_code=403, - details={"spaceKey": space_key, "reason": reason}, - ) - - -def _resolve_space_key_for_payload(payload: Any) -> str | None: - """Extract a ``spaceKey`` from an upstream payload, using the client's - space cache if only ``spaceId`` is present. - - Returns the space key on success; ``None`` if the payload doesn't carry - one (e.g. v1 fallback with no spaceId — caller falls back to a manual - list_spaces lookup). - """ - if not isinstance(payload, dict): - return None - direct = payload.get("spaceKey") or payload.get("space_key") - if isinstance(direct, str) and direct: - return direct - # v2 returns ``spaceId`` on page reads; the client caches the mapping - # opportunistically once ``list_spaces`` runs. - space_id = payload.get("spaceId") - if space_id is None: - space = payload.get("space") - if isinstance(space, dict): - sk = space.get("key") - if isinstance(sk, str) and sk: - return sk - space_id = space.get("id") - if space_id is None: - return None - client = get_confluence_client() - return client.space_cache.key_for_id(str(space_id)) - - -def _resolve_space_key_via_list(allowed: frozenset[str], space_id: str | None) -> str | None: - """Look up a space key for a space id by warming the space cache. - - Used by the post-fetch allowlist check when the page response carries - ``spaceId`` but the cache hasn't been populated yet. Returns ``None`` - if the space isn't visible to the bot (which is itself a deny signal). - - ``allowed`` is unused at this layer; the cache is populated with every - space the bot can see and the post-fetch allowlist check applies the - operator allowlist on the resolved key. - """ - del allowed # cache holds every visible space; allowlist enforced upstream - if not space_id: - return None - client = get_confluence_client() - cached = client.space_cache.key_for_id(str(space_id)) - if cached is not None: - return cached - # Walk paginated /wiki/api/v2/spaces so a target space on page 2+ still - # resolves. populate_space_cache caps iterations defensively. - try: - client.populate_space_cache() - except ( - ConfluenceCredentialsUnavailable, - ConfluenceUpstreamError, - ConfluenceUpstreamForbidden, - ): - # Forbidden on /wiki/api/v2/spaces (bot lacks space:read globally) - # is not its own ConfluenceUpstreamError subclass — catch it here - # so the outer post-fetch check fail-closes through - # confluence_space_denied rather than leaking a Flask 500. - return None - return client.space_cache.key_for_id(str(space_id)) - - -def _confluence_clamp_limit(value: Any) -> int | None: - """Coerce + clamp a caller-supplied limit (1..HARD_MAX_LIMIT).""" - if value is None: - return None - try: - parsed = int(value) - except TypeError, ValueError: - raise ValueError("limit must be an integer") from None - if parsed <= 0: - raise ValueError("limit must be positive") - return min(parsed, CONFLUENCE_HARD_MAX_LIMIT) - - -def _validate_confluence_page_id(page_id: Any) -> tuple[bool, str]: - if not isinstance(page_id, str) or not _CONFLUENCE_PAGE_ID_RE.fullmatch(page_id): - return False, "invalid pageId shape" - return True, "" - - -def _validate_confluence_space_key(space_key: Any) -> tuple[bool, str]: - if not isinstance(space_key, str) or not _CONFLUENCE_SPACE_KEY_RE.fullmatch(space_key): - return False, "invalid spaceKey shape" - return True, "" - - -def _check_post_fetch_space_allowlist( - payload: Any, - *, - allowed: frozenset[str], - page_id: str | None, -) -> tuple[bool, str | None]: - """Verify the response's spaceKey is in the allowlist. - - Returns ``(ok, space_key)``. When ``ok`` is False the route returns - HTTP 403 without forwarding the response body; ``space_key`` is the - resolved key for audit purposes (may be ``None`` if unresolvable). - """ - if not isinstance(payload, dict): - return False, None - if payload.get("status") == "not_found": - # 404 envelope passes through — no space leakage. - return True, None - space_key = _resolve_space_key_for_payload(payload) - if space_key is None: - space_id = payload.get("spaceId") - if isinstance(space_id, (str, int)): - space_key = _resolve_space_key_via_list(allowed, str(space_id)) - if space_key is None: - # Couldn't resolve — fail closed. This protects against the upstream - # response shape changing. - return False, None - return space_key in allowed, space_key - - -@app.route("/api/v1/confluence/page/get", methods=["POST"]) -@require_session_auth -@require_private_mode -def confluence_page_get() -> tuple[Response, int] | Response: - """Fetch a single Confluence page (v2). - - Request body:: - - {"pageId": "12345", - "bodyFormat": ["storage"], - "expand": null} - """ - data = request.get_json(silent=True) or {} - page_id = data.get("pageId") - body_format = data.get("bodyFormat") - expand = data.get("expand") - - ok, reason = _validate_confluence_page_id(page_id) - if not ok: - audit_log( - "confluence_page_get_rejected", - "confluence_page_get", - success=False, - details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, - ) - return make_error( - "Invalid pageId (expected numeric string)", - status_code=400, - details={"pageId": page_id}, - ) - assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id - - allowed = confluence_allowed_spaces() - try: - body = get_confluence_client().get_page(page_id, body_format=body_format, expand=expand) - except ValueError as exc: - audit_log( - "confluence_page_get_rejected", - "confluence_page_get", - success=False, - details={"reason": str(exc), "pageId": page_id, **_session_confluence_context()}, - ) - return make_error(f"Invalid request: {exc}", status_code=400) - except ConfluenceCredentialsUnavailable as exc: - return _confluence_not_configured_error(exc) - except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) - except ConfluenceResponseTooLarge as exc: - audit_log( - "confluence_response_too_large", - "confluence_page_get", - success=False, - details={ - "pageId": page_id, - "size_bytes": exc.size_bytes, - **_session_confluence_context(), - }, - ) - return _confluence_response_too_large(exc, page_id=page_id) - except ConfluenceUpstreamError as exc: - audit_log( - "confluence_page_get_upstream_error", - "confluence_page_get", - success=False, - details={ - "pageId": page_id, - "upstream_status": exc.status_code, - **_session_confluence_context(), - }, - ) - return _confluence_error_from_upstream(exc) - - ok_space, space_key = _check_post_fetch_space_allowlist(body, allowed=allowed, page_id=page_id) - if not ok_space: - return _confluence_space_denied_response( - event="confluence_space_denied", - page_id=page_id, - space_key=space_key, - reason="space not allowlisted", - ) - - audit_log( - "confluence_page_get", - "confluence_page_get", - success=True, - details={ - "pageId": page_id, - "spaceKey": space_key, - "not_found": body.get("status") == "not_found", - **_session_confluence_context(), - }, - ) - return make_success("Confluence page fetched", body) - - -@app.route("/api/v1/confluence/page/descendants", methods=["POST"]) -@require_session_auth -@require_private_mode -def confluence_page_descendants() -> tuple[Response, int] | Response: - """List the descendants of a Confluence page.""" - data = request.get_json(silent=True) or {} - page_id = data.get("pageId") - depth = data.get("depth") - limit_raw = data.get("limit") - cursor = data.get("cursor") - - ok, reason = _validate_confluence_page_id(page_id) - if not ok: - audit_log( - "confluence_page_descendants_rejected", - "confluence_page_descendants", - success=False, - details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, - ) - return make_error( - "Invalid pageId (expected numeric string)", - status_code=400, - details={"pageId": page_id}, - ) - assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id - - # Apply sensible defaults for runaway-tree protection (risk R8). - if depth is None: - depth = 1 - if limit_raw is None: - limit_raw = CONFLUENCE_DEFAULT_LIMIT - try: - limit = _confluence_clamp_limit(limit_raw) - except ValueError as exc: - audit_log( - "confluence_page_descendants_rejected", - "confluence_page_descendants", - success=False, - details={"reason": str(exc), "pageId": page_id, **_session_confluence_context()}, - ) - return make_error(f"Invalid limit: {exc}", status_code=400) - - allowed = confluence_allowed_spaces() - try: - body = get_confluence_client().get_page_descendants( - page_id, - depth=depth, - limit=limit, - cursor=cursor if isinstance(cursor, str) else None, - ) - except ConfluenceCredentialsUnavailable as exc: - return _confluence_not_configured_error(exc) - except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) - except ConfluenceResponseTooLarge as exc: - return _confluence_response_too_large(exc, page_id=page_id) - except ConfluenceUpstreamError as exc: - audit_log( - "confluence_page_descendants_upstream_error", - "confluence_page_descendants", - success=False, - details={ - "pageId": page_id, - "upstream_status": exc.status_code, - **_session_confluence_context(), - }, - ) - return _confluence_error_from_upstream(exc) - - # Resolve the parent page's space for the allowlist check. The - # descendants response doesn't carry it directly, so we fetch the parent - # page once (cheap — the v2 page endpoint is small). - parent_space_key: str | None = None - if body.get("status") != "not_found": - try: - parent = get_confluence_client().get_page(page_id, body_format=("storage",)) - except ( - ConfluenceCredentialsUnavailable, - ConfluenceUpstreamError, - ConfluenceUpstreamForbidden, - ): - parent = None - if parent is not None and parent.get("status") != "not_found": - ok_space, parent_space_key = _check_post_fetch_space_allowlist( - parent, allowed=allowed, page_id=page_id - ) - if not ok_space: - return _confluence_space_denied_response( - event="confluence_space_denied", - page_id=page_id, - space_key=parent_space_key, - reason="space not allowlisted", - ) - else: - return _confluence_space_denied_response( - event="confluence_space_denied", - page_id=page_id, - space_key=None, - reason="parent page space could not be resolved", - ) - - audit_log( - "confluence_page_descendants", - "confluence_page_descendants", - success=True, - details={ - "pageId": page_id, - "spaceKey": parent_space_key, - "depth": depth, - "limit": limit, - **_session_confluence_context(), - }, - ) - return make_success("Confluence descendants fetched", body) - - -@app.route("/api/v1/confluence/page/footer-comments", methods=["POST"]) -@require_session_auth -@require_private_mode -def confluence_page_footer_comments() -> tuple[Response, int] | Response: - """Fetch footer comments on a Confluence page.""" - data = request.get_json(silent=True) or {} - page_id = data.get("pageId") - body_format = data.get("bodyFormat") - include_replies = bool(data.get("includeReplies")) - limit_raw = data.get("limit") - cursor = data.get("cursor") - - ok, reason = _validate_confluence_page_id(page_id) - if not ok: - audit_log( - "confluence_page_footer_comments_rejected", - "confluence_page_footer_comments", - success=False, - details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, - ) - return make_error( - "Invalid pageId (expected numeric string)", - status_code=400, - details={"pageId": page_id}, - ) - assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id - - try: - limit = _confluence_clamp_limit(limit_raw) - except ValueError as exc: - return make_error(f"Invalid limit: {exc}", status_code=400) - - allowed = confluence_allowed_spaces() - try: - body = get_confluence_client().get_page_footer_comments( - page_id, - body_format=body_format, - include_replies=include_replies, - limit=limit, - cursor=cursor if isinstance(cursor, str) else None, - ) - except ValueError as exc: - return make_error(f"Invalid request: {exc}", status_code=400) - except ConfluenceCredentialsUnavailable as exc: - return _confluence_not_configured_error(exc) - except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) - except ConfluenceResponseTooLarge as exc: - return _confluence_response_too_large(exc, page_id=page_id) - except ConfluenceUpstreamError as exc: - audit_log( - "confluence_page_footer_comments_upstream_error", - "confluence_page_footer_comments", - success=False, - details={ - "pageId": page_id, - "upstream_status": exc.status_code, - **_session_confluence_context(), - }, - ) - return _confluence_error_from_upstream(exc) - - parent_space_key: str | None = None - if body.get("status") != "not_found": - try: - parent = get_confluence_client().get_page(page_id, body_format=("storage",)) - except ( - ConfluenceCredentialsUnavailable, - ConfluenceUpstreamError, - ConfluenceUpstreamForbidden, - ): - parent = None - if parent is not None and parent.get("status") != "not_found": - ok_space, parent_space_key = _check_post_fetch_space_allowlist( - parent, allowed=allowed, page_id=page_id - ) - if not ok_space: - return _confluence_space_denied_response( - event="confluence_space_denied", - page_id=page_id, - space_key=parent_space_key, - reason="space not allowlisted", - ) - else: - # Fail-closed when the parent page's space cannot be resolved - # (parent fetch raised, or returned the not_found envelope while - # the comment fetch returned data — Atlassian's per-page - # restriction inheritance can produce exactly this shape). - # We MUST NOT ship the comment body to the sandbox without an - # allowlist verdict. - return _confluence_space_denied_response( - event="confluence_space_denied", - page_id=page_id, - space_key=None, - reason="parent page space could not be resolved", - ) - - audit_log( - "confluence_page_footer_comments", - "confluence_page_footer_comments", - success=True, - details={ - "pageId": page_id, - "spaceKey": parent_space_key, - "includeReplies": include_replies, - **_session_confluence_context(), - }, - ) - return make_success("Confluence footer comments fetched", body) - - -@app.route("/api/v1/confluence/page/inline-comments", methods=["POST"]) -@require_session_auth -@require_private_mode -def confluence_page_inline_comments() -> tuple[Response, int] | Response: - """Fetch inline comments on a Confluence page (with v1 fallback).""" - data = request.get_json(silent=True) or {} - page_id = data.get("pageId") - body_format = data.get("bodyFormat") - limit_raw = data.get("limit") - cursor = data.get("cursor") - - ok, reason = _validate_confluence_page_id(page_id) - if not ok: - audit_log( - "confluence_page_inline_comments_rejected", - "confluence_page_inline_comments", - success=False, - details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, - ) - return make_error( - "Invalid pageId (expected numeric string)", - status_code=400, - details={"pageId": page_id}, - ) - assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id - - try: - limit = _confluence_clamp_limit(limit_raw) - except ValueError as exc: - return make_error(f"Invalid limit: {exc}", status_code=400) - - allowed = confluence_allowed_spaces() - try: - body = get_confluence_client().get_page_inline_comments( - page_id, - body_format=body_format, - limit=limit, - cursor=cursor if isinstance(cursor, str) else None, - ) - except ValueError as exc: - return make_error(f"Invalid request: {exc}", status_code=400) - except ConfluenceCredentialsUnavailable as exc: - return _confluence_not_configured_error(exc) - except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) - except ConfluenceResponseTooLarge as exc: - return _confluence_response_too_large(exc, page_id=page_id) - except ConfluenceUpstreamError as exc: - audit_log( - "confluence_page_inline_comments_upstream_error", - "confluence_page_inline_comments", - success=False, - details={ - "pageId": page_id, - "upstream_status": exc.status_code, - **_session_confluence_context(), - }, - ) - return _confluence_error_from_upstream(exc) - - used_fallback = bool(body.get("used_fallback")) - parent_space_key: str | None = None - if body.get("status") != "not_found": - try: - parent = get_confluence_client().get_page(page_id, body_format=("storage",)) - except ( - ConfluenceCredentialsUnavailable, - ConfluenceUpstreamError, - ConfluenceUpstreamForbidden, - ): - parent = None - if parent is not None and parent.get("status") != "not_found": - ok_space, parent_space_key = _check_post_fetch_space_allowlist( - parent, allowed=allowed, page_id=page_id - ) - if not ok_space: - return _confluence_space_denied_response( - event="confluence_space_denied", - page_id=page_id, - space_key=parent_space_key, - reason="space not allowlisted", - ) - else: - # Fail-closed when the parent page's space cannot be resolved. - # See confluence_page_footer_comments — same risk applies here: - # the v1 fallback can return inline comments even when v2 page - # reads 403, so we MUST NOT ship the body without an allowlist - # verdict. - return _confluence_space_denied_response( - event="confluence_space_denied", - page_id=page_id, - space_key=None, - reason="parent page space could not be resolved", - ) - - audit_log( - "confluence_page_inline_comments", - "confluence_page_inline_comments", - success=True, - details={ - "pageId": page_id, - "spaceKey": parent_space_key, - "used_fallback": used_fallback, - **_session_confluence_context(), - }, - ) - return make_success("Confluence inline comments fetched", body) - - -@app.route("/api/v1/confluence/space/pages", methods=["POST"]) -@require_session_auth -@require_private_mode -def confluence_space_pages() -> tuple[Response, int] | Response: - """List pages in a Confluence space.""" - data = request.get_json(silent=True) or {} - space_key = data.get("spaceKey") - limit_raw = data.get("limit") - cursor = data.get("cursor") - body_format = data.get("bodyFormat") - - ok, reason = _validate_confluence_space_key(space_key) - if not ok: - audit_log( - "confluence_space_pages_rejected", - "confluence_space_pages", - success=False, - details={"reason": reason, "spaceKey": space_key, **_session_confluence_context()}, - ) - return make_error( - "Invalid spaceKey", - status_code=400, - details={"spaceKey": space_key}, - ) - assert isinstance(space_key, str) # narrowed by _validate_confluence_space_key - - if not is_confluence_space_allowed(space_key): - return _confluence_space_denied_response( - event="confluence_space_pages_denied", - page_id=None, - space_key=space_key, - reason="space not allowlisted", - ) - - try: - limit = _confluence_clamp_limit(limit_raw) - except ValueError as exc: - return make_error(f"Invalid limit: {exc}", status_code=400) - - client = get_confluence_client() - - # Resolve spaceKey → spaceId, using the cache when populated. Walk - # paginated /wiki/api/v2/spaces so tenants with more spaces than fit on - # one v2 page still resolve a target on page 2+. - space_id = client.space_cache.id_for_key(space_key) - if space_id is None: - try: - client.populate_space_cache() - except ConfluenceCredentialsUnavailable as exc: - return _confluence_not_configured_error(exc) - except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response( - exc, event="confluence_upstream_403", space_key=space_key - ) - except ConfluenceUpstreamError as exc: - return _confluence_error_from_upstream(exc) - space_id = client.space_cache.id_for_key(space_key) - - if space_id is None: - return make_error( - "Confluence space not found or not visible to bot account", - status_code=404, - details={"status": "not_found", "spaceKey": space_key}, - ) - - try: - body = client.get_space_pages( - space_id, - limit=limit, - cursor=cursor if isinstance(cursor, str) else None, - body_format=body_format, - ) - except ValueError as exc: - return make_error(f"Invalid request: {exc}", status_code=400) - except ConfluenceCredentialsUnavailable as exc: - return _confluence_not_configured_error(exc) - except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response( - exc, event="confluence_upstream_403", space_key=space_key - ) - except ConfluenceResponseTooLarge as exc: - return _confluence_response_too_large(exc, space_key=space_key) - except ConfluenceUpstreamError as exc: - audit_log( - "confluence_space_pages_upstream_error", - "confluence_space_pages", - success=False, - details={ - "spaceKey": space_key, - "upstream_status": exc.status_code, - **_session_confluence_context(), - }, - ) - return _confluence_error_from_upstream(exc) - - audit_log( - "confluence_space_pages", - "confluence_space_pages", - success=True, - details={ - "spaceKey": space_key, - "limit": limit, - **_session_confluence_context(), - }, - ) - return make_success("Confluence space pages fetched", body) - - -@app.route("/api/v1/confluence/space/list", methods=["POST"]) -@require_session_auth -@require_private_mode -def confluence_space_list() -> tuple[Response, int] | Response: - """List Confluence spaces (filtered to the operator's allowlist).""" - data = request.get_json(silent=True) or {} - limit_raw = data.get("limit") - cursor = data.get("cursor") - - try: - limit = _confluence_clamp_limit(limit_raw) - except ValueError as exc: - return make_error(f"Invalid limit: {exc}", status_code=400) - - allowed = confluence_allowed_spaces() - - try: - body = get_confluence_client().list_spaces( - allowed_spaces=allowed, - limit=limit, - cursor=cursor if isinstance(cursor, str) else None, - ) - except ConfluenceCredentialsUnavailable as exc: - return _confluence_not_configured_error(exc) - except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response(exc, event="confluence_upstream_403") - except ConfluenceResponseTooLarge as exc: - return _confluence_response_too_large(exc) - except ConfluenceUpstreamError as exc: - audit_log( - "confluence_space_list_upstream_error", - "confluence_space_list", - success=False, - details={ - "upstream_status": exc.status_code, - **_session_confluence_context(), - }, - ) - return _confluence_error_from_upstream(exc) - - spaces_returned = 0 - if isinstance(body, dict): - results = body.get("results") - if isinstance(results, list): - spaces_returned = len(results) - - audit_log( - "confluence_space_list", - "confluence_space_list", - success=True, - details={ - "spaces_returned": spaces_returned, - **_session_confluence_context(), - }, - ) - return make_success("Confluence spaces fetched", body) - - -@app.route("/api/v1/confluence/search", methods=["POST"]) -@require_session_auth -@require_private_mode -def confluence_search() -> tuple[Response, int] | Response: - """Run a CQL search against Atlassian Cloud Confluence. - - Request body:: - - {"cql": "space = ENG AND text ~ \"rfc\"", - "limit": 50, - "cursor": null} - - The CQL must be statically provable as scoped to allowlisted spaces. - """ - data = request.get_json(silent=True) or {} - cql = data.get("cql") - limit_raw = data.get("limit") - cursor = data.get("cursor") - - if not isinstance(cql, str) or not cql.strip(): - audit_log( - "confluence_search_rejected", - "confluence_search", - success=False, - details={"reason": "cql required", **_session_confluence_context()}, - ) - return make_error("cql is required", status_code=400) - - allowed = confluence_allowed_spaces() - scope = extract_search_spaces(cql, allowed) - if scope.spaces is None: - audit_log( - "confluence_search_rejected", - "confluence_search", - success=False, - details={ - "reason": scope.reason, - "cql_length": len(cql), - **_session_confluence_context(), - }, - ) - return make_error( - f"CQL rejected: {scope.reason}", - status_code=403, - details={"reason": scope.reason}, - ) - - try: - limit = _confluence_clamp_limit(limit_raw) - except ValueError as exc: - return make_error(f"Invalid limit: {exc}", status_code=400) - - try: - body = get_confluence_client().search_cql( - cql=cql, - limit=limit, - cursor=cursor if isinstance(cursor, str) else None, - ) - except ConfluenceCredentialsUnavailable as exc: - return _confluence_not_configured_error(exc) - except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response(exc, event="confluence_upstream_403") - except ConfluenceResponseTooLarge as exc: - return _confluence_response_too_large(exc) - except ConfluenceUpstreamError as exc: - audit_log( - "confluence_search_upstream_error", - "confluence_search", - success=False, - details={ - "upstream_status": exc.status_code, - **_session_confluence_context(), - }, - ) - return _confluence_error_from_upstream(exc) - - audit_log( - "confluence_search", - "confluence_search", - success=True, - details={ - "spaces_extracted": sorted(scope.spaces), - "cql_length": len(cql), - "limit": limit, - "cursor_present": bool(cursor), - **_session_confluence_context(), - }, - ) - return make_success("Confluence search executed", body) - - -@app.route("/api/v1/confluence/execute", methods=["POST"]) -@require_session_auth -@require_private_mode -def confluence_execute() -> tuple[Response, int] | Response: - """Generic read-only passthrough for whitelisted Confluence REST paths. - - Request body:: - - {"method": "GET", - "path": "api/v2/pages/12345", - "query": {"body-format": "storage"}, - "body": null} - """ - data = request.get_json(silent=True) or {} - method = data.get("method") or "GET" - path = data.get("path") - query = data.get("query") - req_body = data.get("body") - - if not isinstance(path, str) or not path: - audit_log( - "confluence_execute_rejected", - "confluence_execute", - success=False, - details={"reason": "path required", **_session_confluence_context()}, - ) - return make_error("path is required", status_code=400) - - if not isinstance(method, str): - audit_log( - "confluence_execute_rejected", - "confluence_execute", - success=False, - details={"reason": "method must be a string", **_session_confluence_context()}, - ) - return make_error("method must be a string", status_code=400) - - method_upper = method.upper() - ok, reason = validate_confluence_api_path(path, method_upper) - if not ok: - audit_log( - "confluence_execute_denied", - "confluence_execute", - success=False, - details={ - "method": method_upper, - "path": path, - "reason": reason, - **_session_confluence_context(), - }, - ) - return make_error( - f"Confluence API call rejected: {reason}", - status_code=403, - details={"method": method_upper, "path": path, "reason": reason}, - ) - - stripped = path.strip("/").split("?", 1)[0] - head = stripped.split("/") - page_id: str | None = None - space_id_in_path: str | None = None - if len(head) >= 4 and head[0] == "api" and head[1] == "v2" and head[2] == "pages": - # api/v2/pages/ - if head[3].isdigit(): - page_id = head[3] - elif len(head) >= 5 and head[0] == "api" and head[1] == "v2" and head[2] == "spaces": - # api/v2/spaces//pages - if head[3].isdigit(): - space_id_in_path = head[3] - - # Anti-bypass invariant (issue #1931 cycle-3 NACK from reviewer_code + - # reviewer_security): the four path families an attacker could use to - # bypass narrow-route safeguards — ``rest/api/search`` (CQL extractor - # bypass), ``api/v2/spaces`` (allowlist-filter bypass), - # ``api/v2/footer-comments`` / ``api/v2/inline-comments`` (flat - # endpoints with page-id-in-query and no upstream spaceKey filter) — - # are dropped from CONFLUENCE_API_ALLOWED_PATHS in confluence_client.py, - # so reaching this point implies a page- or space-scoped path family. - # All of those carry an id inline that the post-fetch allowlist check - # below resolves to a spaceKey. - - if query is not None and not isinstance(query, dict): - return make_error("query must be an object", status_code=400) - if req_body is not None and not isinstance(req_body, dict): - return make_error("body must be an object", status_code=400) - - allowed = confluence_allowed_spaces() - client = get_confluence_client() - - try: - body = client.execute_raw( - method=method_upper, - path=stripped, - query=query, - body=req_body, - ) - except ConfluenceCredentialsUnavailable as exc: - return _confluence_not_configured_error(exc) - except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) - except ConfluenceResponseTooLarge as exc: - return _confluence_response_too_large(exc, page_id=page_id) - except ConfluenceUpstreamError as exc: - audit_log( - "confluence_execute_upstream_error", - "confluence_execute", - success=False, - details={ - "method": method_upper, - "path": stripped, - "upstream_status": exc.status_code, - **_session_confluence_context(), - }, - ) - return _confluence_error_from_upstream(exc) - - # Post-fetch allowlist check for path families that carry an id inline. - audited_space_key: str | None = None - if page_id is not None and isinstance(body, dict) and body.get("status") != "not_found": - ok_space, audited_space_key = _check_post_fetch_space_allowlist( - body, allowed=allowed, page_id=page_id - ) - if not ok_space: - return _confluence_space_denied_response( - event="confluence_execute_denied", - page_id=page_id, - space_key=audited_space_key, - reason="space not allowlisted", - extra={"method": method_upper, "path": stripped}, - ) - elif space_id_in_path is not None: - resolved = client.space_cache.key_for_id(space_id_in_path) - if resolved is None: - # Walk paginated /wiki/api/v2/spaces so a target on page 2+ - # still resolves. Catch ConfluenceUpstreamForbidden alongside - # the other upstream errors — it's a sibling of - # ConfluenceUpstreamError (both inherit from RuntimeError, not - # one from the other) and would otherwise escape as a Flask - # 500 when the bot lacks space:read globally. Mirrors the - # handler at _resolve_space_key_via_list. - try: - client.populate_space_cache() - except ( - ConfluenceCredentialsUnavailable, - ConfluenceUpstreamError, - ConfluenceUpstreamForbidden, - ): - resolved = None - else: - resolved = client.space_cache.key_for_id(space_id_in_path) - if resolved is None or resolved not in allowed: - return _confluence_space_denied_response( - event="confluence_execute_denied", - page_id=None, - space_key=resolved, - reason="space not allowlisted", - extra={"method": method_upper, "path": stripped}, - ) - audited_space_key = resolved - - audit_log( - "confluence_execute", - "confluence_execute", - success=True, - details={ - "method": method_upper, - "path": stripped, - "pageId": page_id, - "spaceKey": audited_space_key, - **_session_confluence_context(), - }, - ) - return make_success("Confluence API call executed", body) - - -# ============================================================================= -# Worktree Lifecycle Endpoints -# ============================================================================= - -# Global WorktreeManager instance -_worktree_manager: WorktreeManager | None = None - - -def get_worktree_manager() -> WorktreeManager: - """Get or create the global WorktreeManager instance.""" - global _worktree_manager - if _worktree_manager is None: - _worktree_manager = WorktreeManager() - return _worktree_manager - - -def map_container_path_to_worktree( - repo_path: str, container_id: str | None, operation: str = "git" -) -> str | None: - """ - Map a container's repo path to the corresponding worktree path. - - Container sends paths like /home/egg/repos/{repo} or subdirectories like - /home/egg/repos/{repo}/src/foo, but the gateway needs to run git in the - worktree at /home/egg/.egg-worktrees/{container_id}/{repo}[/subdir]. - - Args: - repo_path: The path sent by the container (e.g., /home/egg/repos/myrepo/src) - container_id: The container's unique identifier - operation: Name of the operation for logging purposes - - Returns: - The worktree path if mapping succeeds, the original repo_path if no - container_id was provided (interactive session), or None if a - container_id was provided but the worktree could not be found. - """ - if not container_id: - return repo_path - - # Extract repo name and any subdirectory from paths like: - # /home/egg/repos/myrepo -> repo_name=myrepo, subdir="" - # /home/egg/repos/myrepo/src/foo -> repo_name=myrepo, subdir="src/foo" - repos_prefix = "/home/egg/repos/" - if not repo_path.startswith(repos_prefix): - return repo_path - - # Get the path relative to /home/egg/repos/ - relative_path = repo_path[len(repos_prefix) :].rstrip("/") - if not relative_path: - # Path is exactly /home/egg/repos/ - not a repo - return repo_path - - # Split into repo name and subdirectory - parts = relative_path.split("/", 1) - repo_name = parts[0] - subdir = parts[1] if len(parts) > 1 else "" - - if not repo_name: - return repo_path - - manager = get_worktree_manager() - try: - worktree_path, _main_repo = manager.get_worktree_paths(container_id, repo_name) - if worktree_path.exists(): - # Append subdirectory if present - final_path = worktree_path / subdir if subdir else worktree_path - logger.debug( - f"Mapped container path to worktree for {operation}", - container_path=repo_path, - worktree_path=str(final_path), - container_id=container_id, - ) - return str(final_path) - else: - logger.warning( - f"Worktree path does not exist for {operation} — " - f"container_id may not match any created worktree", - container_path=repo_path, - expected_worktree=str(worktree_path), - container_id=container_id, - ) - return None - except ValueError as e: - logger.warning( - f"Failed to map container path to worktree for {operation}", - error=str(e), - container_id=container_id, - repo_name=repo_name, - ) - return None - - -def _cleanup_stale_pack_files(exec_path: str) -> None: - """Best-effort opportunistic cleanup of previously-orphaned temporary pack files. - - Called after a git operation times out, but does NOT target the specific - operation's artifacts (those are too recent to match the age filter). - Instead, it scans for ``tmp_pack_*``/``tmp_obj_*``/``tmp_idx_*`` files - older than 5 minutes — orphans left by *earlier* interrupted operations. - The age filter avoids racing with concurrent fetch operations on the same - repository. - """ - try: - # Determine repo_name from exec_path. - # Worktree paths: /home/egg/.egg-worktrees/{container_id}/{repo_name}[/subdir] - # Main repo paths: /home/egg/repos/{repo_name}[/subdir] - repo_name = None - worktree_prefix = str(WORKTREE_BASE_DIR) + "/" - repos_prefix = str(REPOS_BASE_DIR) + "/" - - if exec_path.startswith(worktree_prefix): - # e.g. /home/egg/.egg-worktrees/container-123/my-repo/src → my-repo - relative = exec_path[len(worktree_prefix) :] - parts = relative.split("/") - if len(parts) >= 2: - repo_name = parts[1] - elif exec_path.startswith(repos_prefix): - # e.g. /home/egg/repos/my-repo/src → my-repo - relative = exec_path[len(repos_prefix) :] - parts = relative.split("/") - if parts and parts[0]: - repo_name = parts[0] - - if not repo_name: - return - - manager = get_worktree_manager() - manager.cleanup_orphaned_pack_files( - repo_name=repo_name, - max_age_seconds=300, - ) - except Exception as e: - logger.debug( - "Stale pack file cleanup failed (best-effort)", - exec_path=exec_path, - error=str(e), - ) - - -def _cleanup_empty_container_dir(container_id: str) -> None: - """Best-effort removal of an orphan container worktree directory. - - Called from the total-failure branch of worktree_create. Only acts - when the directory is actually empty — if any per-repo subdir is - present (partial failure with leftover state), we leave it for an - operator-driven prune so we don't accidentally drop in-progress - work. See #2186. - - Defense-in-depth: validate `container_id` and verify the resolved - path stays under `WORKTREE_BASE_DIR` before any filesystem mutation. - `worktree_create` already validates at the route boundary, but a - raw `..`-bearing identifier reaching `Path / container_id` would - otherwise let `rmdir(2)` follow the literal path and unlink an - empty directory adjacent to the base dir. - """ - try: - validate_identifier(container_id, "container_id") - except ValueError as e: - logger.warning( - "Skipping orphan container dir cleanup: invalid container_id", - container_id=container_id, - error=str(e), - ) - return - - target = WORKTREE_BASE_DIR / container_id - try: - # Resolve and verify containment as a second line of defense - # against any future caller that bypasses validate_identifier. - base_resolved = WORKTREE_BASE_DIR.resolve() - target_resolved = target.resolve(strict=False) - if target_resolved != base_resolved and base_resolved not in target_resolved.parents: - logger.warning( - "Skipping orphan container dir cleanup: outside base dir", - container_id=container_id, - resolved=str(target_resolved), - base=str(base_resolved), - ) - return - if not target.exists(): - return - if any(target.iterdir()): - logger.warning( - "Skipping orphan container dir cleanup: not empty", - container_id=container_id, - path=str(target), - ) - return - target.rmdir() - logger.info( - "Removed empty orphan container worktree dir", - container_id=container_id, - path=str(target), - ) - except OSError as e: - logger.warning( - "Failed to clean orphan container dir", - container_id=container_id, - path=str(target), - error=str(e), - ) - - -@app.route("/api/v1/worktree/create", methods=["POST"]) -@require_launcher_auth -def worktree_create() -> tuple[Response, int] | Response: - """ - Create worktrees for a container. - - Called by the egg launcher before starting a container. Creates isolated - worktrees for each repository the container needs access to. - - Request body: - { - "container_id": "egg-xxx-yyy", - "repos": ["owner/repo1", "owner/repo2"], - "uid": 1000, // optional, defaults to 1000 (egg user) - "gid": 1000 // optional, defaults to 1000 (egg group) - } - - Returns: - { - "success": true, - "message": "Worktrees created", - "data": { - "worktrees": { - "repo1": "/home/user/.egg-worktrees/egg-xxx-yyy/repo1", - "repo2": "/home/user/.egg-worktrees/egg-xxx-yyy/repo2" - } - } - } - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - container_id = data.get("container_id") - repos = data.get("repos", []) - base_branch = data.get("base_branch") # None = resolve per-repo - assigned_branch = data.get("assigned_branch") # None = skip upstream config - # #3393 slice-7: when True, materialize each repo's pipeline work - # branch on its OWN remote right after the worktree exists (push the - # worktree HEAD to refs/heads/{assigned_branch or work-branch}). - # Multi-repo pipelines set this so secondary-repo context / slice PRs - # find a head branch; single-repo (N=1) callers leave it False, so - # the path stays byte-identical to pre-#3393. - push_branch = bool(data.get("push_branch", False)) - # UID/GID for worktree ownership (default: 1000 for egg user) - uid = data.get("uid") - gid = data.get("gid") - - if not container_id: - return make_error("Missing container_id") - if not repos: - return make_error("Missing repos list") - - # Validate container_id at the route boundary so every downstream - # filesystem touch (including the post-failure cleanup helper) is - # safe. Without this, a `..`-bearing container_id would reach - # `_cleanup_empty_container_dir` after `manager.create_worktree` - # raised ValueError into the per-repo `errors[]` list, letting - # `rmdir(2)` follow the literal path out of WORKTREE_BASE_DIR. - # See #2186 review feedback. - try: - validate_identifier(container_id, "container_id") - except ValueError as e: - return make_error(str(e)) - - # Validate uid/gid if provided - if uid is not None and (not isinstance(uid, int) or uid < 0): - return make_error("Invalid uid: must be a non-negative integer") - if gid is not None and (not isinstance(gid, int) or gid < 0): - return make_error("Invalid gid: must be a non-negative integer") - - manager = get_worktree_manager() - worktrees = {} - errors = [] - - for repo in repos: - # Extract repo name from owner/repo format - if "/" in repo: - repo_name = repo.split("/")[-1] - else: - repo_name = repo - - # Pre-bind so the except clauses below can reference - # effective_branch even if resolve_default_branch raises (e.g., - # OSError if the git binary is missing). See #2186 review - # feedback — previously hoisted out of the try, which let - # resolve_default_branch failures bypass the per-repo errors[] - # safety net entirely. - effective_branch = base_branch - try: - # Resolve the default branch per-repo when no explicit base is given. - # This ensures repos with non-standard default branches (e.g., master) - # are handled correctly. See #860. - effective_branch = base_branch or manager.resolve_default_branch(repo_name) - info = manager.create_worktree( - repo_name=repo_name, - container_id=container_id, - base_branch=effective_branch, - uid=uid, - gid=gid, - assigned_branch=assigned_branch, - repo_slug=repo, - push_branch=push_branch, - ) - # Translate container path to host path for egg launcher mount sources. - # Key by the full ``owner/repo`` slug (#3393 slice-3, operator - # ruling #6) so two repos with the same short name under different - # owners (``ownerA/foo`` vs ``ownerB/foo``) no longer collide on a - # single map entry. When the caller passed a bare repo name (no - # ``/``), ``repo`` equals ``repo_name`` so bare-name callers are - # unaffected. The on-disk worktree directory (and the container - # mount target) stays the bare ``repo_name`` — only the map KEY - # carries the owner prefix. - worktrees[repo] = translate_to_host_path(str(info.worktree_path)) - except (ValueError, RuntimeError) as e: - # Capture full traceback so operators can diagnose without - # re-instrumenting the gateway. See #2186. - logger.exception( - "worktree_create per-repo failure", - repo_name=repo_name, - container_id=container_id, - base_branch=effective_branch, - assigned_branch=assigned_branch, - error_type=type(e).__name__, - ) - errors.append(f"{repo_name}: {e}") - except Exception as e: - logger.exception( - "worktree_create unexpected per-repo failure", - repo_name=repo_name, - container_id=container_id, - base_branch=effective_branch, - assigned_branch=assigned_branch, - error_type=type(e).__name__, - ) - errors.append(f"{repo_name}: unexpected error - {e}") - - if errors and not worktrees: - # Total failure: surface aggregate errors in logs + audit, then - # best-effort clean up the empty container directory so retries - # aren't blocked by stale state. See #2186. - logger.error( - "worktree_create failed for all repos", - container_id=container_id, - errors=errors, - ) - audit_log( - "worktrees_create_failed", - "worktree_create", - success=False, - details={ - "container_id": container_id, - "errors": errors, - }, - ) - _cleanup_empty_container_dir(container_id) - return make_error( - "Failed to create any worktrees", - status_code=500, - details={"errors": errors}, - ) - - audit_log( - "worktrees_created", - "worktree_create", - success=True, - details={ - "container_id": container_id, - "repos": list(worktrees.keys()), - "errors": errors, - }, - ) - - return make_success( - "Worktrees created", - { - "worktrees": worktrees, - "errors": errors if errors else None, - }, - ) - - -@app.route("/api/v1/worktree/delete", methods=["POST"]) -@require_launcher_auth -def worktree_delete() -> tuple[Response, int] | Response: - """ - Delete worktrees for a container. - - Called by the egg launcher when a container exits. Removes the worktrees - and associated branches. - - Request body: - { - "container_id": "egg-xxx-yyy", - "force": false # optional, force remove even with uncommitted changes - } - - Returns: - { - "success": true, - "message": "Worktrees deleted", - "data": { - "deleted": ["repo1", "repo2"], - "warnings": ["repo1: had uncommitted changes"] - } - } - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - container_id = data.get("container_id") - force = data.get("force", False) - - if not container_id: - return make_error("Missing container_id") - - manager = get_worktree_manager() - - # Get list of worktrees for this container - worktree_dir = manager.worktree_base / container_id - if not worktree_dir.exists(): - return make_success("No worktrees to delete", {"deleted": []}) - - deleted = [] - errors = [] - warnings = [] - - # Iterate through worktree directories - for repo_dir in list(worktree_dir.iterdir()): - if not repo_dir.is_dir(): - continue - - repo_name = repo_dir.name - - try: - result = manager.remove_worktree( - container_id=container_id, - repo_name=repo_name, - force=force, - ) - - if result.success: - deleted.append(repo_name) - if result.warning: - warnings.append(f"{repo_name}: {result.warning}") - elif result.uncommitted_changes and not force: - errors.append(f"{repo_name}: has uncommitted changes (use force=true)") - elif result.error: - errors.append(f"{repo_name}: {result.error}") - else: - errors.append(f"{repo_name}: removal failed") - except Exception as e: - errors.append(f"{repo_name}: unexpected error - {e}") - - audit_log( - "worktrees_deleted", - "worktree_delete", - success=True, - details={ - "container_id": container_id, - "deleted": deleted, - "errors": errors, - }, - ) - - return make_success( - "Worktrees deleted", - { - "deleted": deleted, - "errors": errors if errors else None, - "warnings": warnings if warnings else None, - }, - ) - - -@app.route("/api/v1/worktree/list", methods=["GET"]) -@require_launcher_auth -def worktree_list() -> tuple[Response, int] | Response: - """ - List all active worktrees. - - Returns information about all worktrees managed by the gateway. - """ - manager = get_worktree_manager() - worktrees = manager.list_worktrees() - return make_success("Worktrees listed", {"worktrees": worktrees}) - - -# Shared mutex: single lock shared by the prune route so that a -# dry_run=true and dry_run=false call can never interleave. Local to -# this module; tests exercise `request` against the Flask test client -# so the process-wide lock is safe. -_worktree_prune_lock = threading.Lock() - - -# Slice-scoped per-agent worktree dir suffix: -# ``{pipeline_id}-slice-{N}-{role}``. The role part follows AgentRole -# values (``[a-z_]+``). Used by ``_derive_worktree_anchor_ids`` to -# recognise slice-scoped worktrees of a live pipeline that the session -# metadata alone can't express (sessions carry pipeline_id + agent_role -# but not slice_id). -_SLICE_WORKTREE_SUFFIX_RE = re.compile(r"^-slice-[0-9]+-[a-z_]+$") - - -def _derive_worktree_anchor_ids(sessions: list[dict[str, Any]]) -> set[str]: - """Return the set of worktree directory names implied by active sessions. - - Session ``container_id`` is the k8s Job name / Docker container name - (e.g. ``egg-agent-issue-1758-again-coder``), but per-agent worktrees - on disk are named after the orchestrator-assigned - ``agent_worktree_id`` of the form ``{pipeline_id}-{agent_role}`` and - the pipeline-level worktree is just ``{pipeline_id}``. Without this - derivation, ``cleanup_orphaned_worktrees`` treats every per-agent - worktree as an orphan, which wiped live pipelines' worktrees during - gateway startup cleanup (#1874). - - Slice-scoped per-agent worktrees (``{pipeline_id}-slice-{N}-{role}``, - introduced in #2403) carry no slice context on the session, so for - each live pipeline we additionally scan the worktree base for - matching directories and add them to the anchor set. Without this - scan, slice-scoped agent worktrees with unpushed local commits are - wiped during gateway startup cleanup — silently destroying the work - that ``salvage_agent_commits`` was designed to recover (#2463). - """ - anchors: set[str] = set() - pipeline_ids: set[str] = set() - for session_info in sessions: - pipeline_id = session_info.get("pipeline_id") - agent_role = session_info.get("agent_role") - if pipeline_id: - anchors.add(pipeline_id) - pipeline_ids.add(pipeline_id) - if agent_role: - anchors.add(f"{pipeline_id}-{agent_role}") - - if pipeline_ids: - try: - entries = list(WORKTREE_BASE_DIR.iterdir()) - except OSError: - entries = [] - for entry in entries: - try: - if not entry.is_dir(): - continue - except OSError: - continue - name = entry.name - for pid in pipeline_ids: - if not name.startswith(pid): - continue - suffix = name[len(pid) :] - if _SLICE_WORKTREE_SUFFIX_RE.match(suffix): - anchors.add(name) - break - return anchors - - -def _container_ids_from_sessions(sessions: list[dict[str, Any]]) -> set[str]: - """Return container IDs and worktree anchor IDs from session dicts. - - Each session contributes its own ``container_id`` plus the derived - per-agent (``{pipeline_id}-{agent_role}``), pipeline-level - (``{pipeline_id}``), and slice-scoped - (``{pipeline_id}-slice-{N}-{role}``) worktree anchor IDs so that - cleanup never wipes a live pipeline's worktrees (#1874, #2463). - """ - ids: set[str] = set() - for session_info in sessions: - cid = session_info.get("container_id") - if cid: - ids.add(cid) - ids |= _derive_worktree_anchor_ids(sessions) - return ids - - -def _collect_active_container_ids() -> set[str]: - """Return the best-effort set of container IDs that back live sessions. - - Mirrors the startup-cleanup logic at module level so the prune route - never issues a sweep with an empty active set (which would otherwise - treat every worktree as an orphan — see #1759 review). - - Consults: - - 1. Persisted sessions via :func:`get_session_manager` (primary source - of truth — survives gateway restarts). Each session contributes - its own ``container_id`` plus the derived per-agent and - pipeline-level worktree anchor IDs so that cleanup never wipes a - live pipeline's worktrees (#1874). - 2. ``docker ps`` when Docker is reachable (safety net for sessions - that outlive the session-manager snapshot). - - Failures in either step degrade silently so the prune still runs — - the worst case is that a genuinely orphaned dir is preserved, which - the next scheduled prune will clean up. - """ - active_container_ids: set[str] = set() - try: - session_manager = get_session_manager() - sessions = session_manager.list_sessions() - active_container_ids |= _container_ids_from_sessions(sessions) - except Exception as exc: - logger.warning( - "prune: session-manager active-container lookup failed", - error=str(exc), - ) - try: - active_container_ids |= get_active_docker_containers() - except Exception as exc: - # Non-fatal: on k3s there is no dockerd reachable from the - # orchestrator's sidecar, and that is fine. - logger.debug( - "prune: docker active-container probe unavailable", - error=str(exc), - ) - return active_container_ids - - -@app.route("/api/v1/worktrees/prune", methods=["POST"]) -@require_launcher_auth -def worktrees_prune() -> tuple[Response, int] | Response: - """ - Run ``git worktree prune`` across every repo and sweep orphan dirs - under the worktree base. - - Request body:: - - {"dry_run": bool = true} - - When ``dry_run`` is true, returns the set of orphan directories - that would be removed but does not mutate the filesystem. When - false, removes them using the existing ``cleanup_orphaned_worktrees`` - helper. The active-container set is derived from the session - manager (plus an opportunistic ``docker ps`` fallback), and worktrees - anchored to a non-terminal pipeline (per the orchestrator) are - preserved even with no live container — a pipeline parked at a HITL - gate has neither (#3070). Returns 503 when pipeline liveness cannot - be verified. - - Proxied from the orchestrator's - ``/api/v1/deployment/prune-worktrees`` endpoint (#1759). - """ - data = request.get_json(silent=True) or {} - dry_run = bool(data.get("dry_run", True)) - - manager = get_worktree_manager() - - # Serialize all prune activity — git operations on the same repo - # must not interleave even if two callers hit this endpoint - # concurrently. - if not _worktree_prune_lock.acquire(timeout=60): - return make_error("Another worktree prune is in progress", status_code=409) - try: - # Pipeline liveness is required, not best-effort: a parked pipeline - # has no containers or sessions, so the container-derived set alone - # would mark its worktree an orphan (#3070). This endpoint is proxied - # from the orchestrator, so it is normally up; refuse rather than - # sweep blind if it cannot answer. - active_pipeline_ids = fetch_active_pipeline_ids() - if active_pipeline_ids is None: - return make_error( - "Cannot verify pipeline liveness (orchestrator unreachable); " - "refusing to prune worktrees", - status_code=503, - ) - - active_container_ids = _collect_active_container_ids() - git_prune_report = manager.git_worktree_prune_all() - orphan_dirs = manager.list_orphan_worktree_dirs( - active_containers=active_container_ids, - active_pipeline_ids=active_pipeline_ids, - ) - - removed_count = 0 - removed_paths: list[str] = [] - if not dry_run and orphan_dirs: - removed_count = manager.cleanup_orphaned_worktrees( - active_containers=active_container_ids, - active_pipeline_ids=active_pipeline_ids, - ) - # Any orphan we enumerated that no longer exists on disk - # was removed by the helper. - for path in orphan_dirs: - try: - if not Path(path).exists(): - removed_paths.append(path) - except OSError: - pass - - audit_log( - "worktrees_pruned", - "worktrees_prune", - success=True, - details={ - "dry_run": dry_run, - "git_worktree_prune": git_prune_report, - "orphan_dirs_count": len(orphan_dirs), - "active_containers_count": len(active_container_ids), - "active_pipelines_count": len(active_pipeline_ids), - "removed_count": removed_count, - }, - ) - - return make_success( - "Worktree prune complete", - { - "dry_run": dry_run, - "git_worktree_prune": git_prune_report, - "orphan_dirs": orphan_dirs, - "active_containers_count": len(active_container_ids), - "active_pipelines_count": len(active_pipeline_ids), - "removed_count": removed_count, - "removed_paths": removed_paths, - }, - ) - finally: - _worktree_prune_lock.release() - - -# ============================================================================= -# Session Management Endpoints (Per-Container Repository Mode) -# ============================================================================= - - -def _branch_exists_on_remote(manager: WorktreeManager, repo_name: str, branch: str) -> bool: - """Check if a branch exists on the remote (origin) for a repository. - - Args: - manager: WorktreeManager instance (provides repos_base path) - repo_name: Name of the repository - branch: Branch name without origin/ prefix (e.g., "egg/issue-42/work") - - Returns: - True if origin/{branch} exists, False otherwise. - """ - main_repo = manager.repos_base / repo_name - if not main_repo.exists(): - return False - # Uses local tracking refs (origin/*) rather than querying the remote. - # This is reliable here because the gateway handles push/fetch operations - # which keep tracking refs up to date. If stale refs ever become an - # issue, switch to `git ls-remote --exit-code origin {branch}`. - result = subprocess.run( - git_cmd("rev-parse", "--verify", f"origin/{branch}"), - cwd=main_repo, - capture_output=True, - text=True, - check=False, - ) - return result.returncode == 0 - - -@app.route("/api/v1/sessions/create", methods=["POST"]) -@require_launcher_auth -def session_create() -> tuple[Response, int] | Response: - """ - Create a session with atomic visibility query, filtering, worktree creation. - - This is the primary endpoint for session registration. It performs: - 1. Query repository visibility for all requested repos - 2. Filter repos based on mode (private keeps private/internal, public keeps public) - 3. Create worktrees for filtered repos - 4. Register session with the filtered repo list - - This atomic operation prevents TOCTOU race conditions between visibility - check and session registration. - - Request body: - { - "container_id": "egg-xxx", - "container_ip": "172.18.0.3", - "mode": "private"|"public", - "repos": ["owner/repo1", "owner/repo2"], - "local_only_repos": ["repo-name"], // optional; no GitHub remote - "uid": 1000, - "gid": 1000, - // Optional: when the caller already created per-agent worktrees - // via /api/v1/worktrees/create, pass that container_id here so - // the session reuses the existing worktrees instead of racing - // to re-create them on the same bare repo (#1857). - "worktree_container_id": "pipeline-role" - } - - Response: - { - "success": true, - "session_token": "tok_...", - "filtered_repos": ["owner/repo1"], - "worktrees": { - "repo1": "/path/to/worktree" - } - } - - Auth: Bearer {launcher_secret} - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - container_id = data.get("container_id") - container_ip = data.get("container_ip") - mode = data.get("mode") - repos = data.get("repos", []) - local_only_repos = data.get("local_only_repos", []) - uid = data.get("uid") - gid = data.get("gid") - # Optional worktree container_id — when provided, look up existing - # worktrees created by a prior /api/v1/worktrees/create call instead of - # re-creating them here. Prevents the double create that races on - # .git/config.lock for concurrent per-agent spawns (#1857). - worktree_container_id = data.get("worktree_container_id") - phase = data.get("phase") # Optional SDLC pipeline phase - pipeline_id = data.get("pipeline_id") # Optional pipeline run ID - issue_number = data.get("issue_number") # Optional GitHub issue number - pr_number = data.get("pr_number") # Optional GitHub PR number - agent_role = data.get("agent_role") # Optional agent role - agent_anchor_id = data.get("agent_anchor_id") # Optional agent anchor ID - claude_code_version = data.get("claude_code_version") # Optional Claude Code version - branch = data.get("branch") # Optional git branch for non-pushing sessions - # Optional pipeline base branch (PR base). Stored on the session and used - # as the preferred diff base for the new-branch restricted-path push check - # (#3024). Distinct from the locally-derived ``worktree_base_branch`` below, - # which selects the ref a fresh worktree forks from. - session_base_branch = data.get("base_branch") - jira_ticket = data.get("jira_ticket") # Optional Atlassian ticket key — advisory only - synthetic = data.get("synthetic", False) # Orchestrator-internal temp session - # Per-session upstream routing (issue #2769). Default to "anthropic" so - # pre-#2769 callers keep byte-identical session shape. - upstream = data.get("upstream", "anthropic") - upstream_model = data.get("upstream_model") - - # Validate required fields - if not container_id: - return make_error("Missing container_id") - # container_ip is optional — k8s pod IPs are ephemeral and may not be - # known at session creation time. When omitted, token-only auth is used. - # Kept for backward compatibility with Docker-based deployments. - if mode not in ("private", "public"): - return make_error("Invalid mode: must be 'private' or 'public'") - # repos can be omitted for orchestrator-internal sessions that have a pipeline_id - if not repos and not local_only_repos and not pipeline_id: - return make_error("Missing repos list") - - # Validate uid/gid if provided - if uid is not None and (not isinstance(uid, int) or uid < 0): - return make_error("Invalid uid: must be a non-negative integer") - if gid is not None and (not isinstance(gid, int) or gid < 0): - return make_error("Invalid gid: must be a non-negative integer") - - # Validate phase if provided - if phase is not None and phase not in VALID_PIPELINE_PHASES: - return make_error( - f"Invalid phase: {phase}. Must be one of: {', '.join(sorted(VALID_PIPELINE_PHASES))}" - ) - - # Validate pipeline_id if provided - if pipeline_id is not None: - if not isinstance(pipeline_id, str): - return make_error("Invalid pipeline_id: must be a string") - if not pipeline_id: - return make_error("Invalid pipeline_id: must be a non-empty string") - if len(pipeline_id) > 256: - return make_error("Invalid pipeline_id: must be 256 characters or fewer") - - # Validate base_branch if provided (#3024) - # - # Defense-in-depth: ``base_branch`` flows into ``git fetch`` and - # ``git merge-base origin/ HEAD`` as positional argv (see - # ``get_changed_files_in_push`` / ``_enumerate_push_commits``). A value - # starting with ``-`` would otherwise be interpreted as a git flag (the - # historical ``--upload-pack=...`` shape that has produced git RCEs). - # The orchestrator already validates ``base_branch`` at pipeline submission - # and the launcher secret gates this endpoint, but the gateway must not - # rely on caller hygiene here — ``validate_branch_ref`` rejects leading - # dashes, ``..``, null bytes, ``//``, and other unsafe ref shapes. - if session_base_branch is not None: - if not isinstance(session_base_branch, str): - return make_error("Invalid base_branch: must be a string") - if not session_base_branch: - return make_error("Invalid base_branch: must be a non-empty string") - if len(session_base_branch) > 256: - return make_error("Invalid base_branch: must be 256 characters or fewer") - try: - validate_branch_ref(session_base_branch, "base_branch") - except ValueError as exc: - return make_error(str(exc)) - - # Validate issue_number if provided - if issue_number is not None and (not isinstance(issue_number, int) or issue_number < 1): - return make_error("Invalid issue_number: must be a positive integer") - - # Validate pr_number if provided - if pr_number is not None and (not isinstance(pr_number, int) or pr_number < 1): - return make_error("Invalid pr_number: must be a positive integer") - - # Validate agent_role if provided - if agent_role is not None: - if not isinstance(agent_role, str): - return make_error("Invalid agent_role: must be a string") - if not agent_role: - return make_error("Invalid agent_role: must be non-empty if provided") - if len(agent_role) > 64: - return make_error("Invalid agent_role: must be 64 characters or fewer") - - # Validate agent_anchor_id if provided - if agent_anchor_id is not None: - if not isinstance(agent_anchor_id, str): - return make_error("Invalid agent_anchor_id: must be a string") - if len(agent_anchor_id) > 128: - return make_error("Invalid agent_anchor_id: must be 128 characters or fewer") - if not re.match(r"^[a-zA-Z0-9_-]+$", agent_anchor_id): - return make_error( - "Invalid agent_anchor_id: must contain only alphanumeric characters, hyphens, and underscores" - ) - - # Validate claude_code_version if provided - if claude_code_version is not None: - if not isinstance(claude_code_version, str): - return make_error("Invalid claude_code_version: must be a string") - if len(claude_code_version) > 64: - return make_error("Invalid claude_code_version: must be 64 characters or fewer") - - # Validate branch if provided - # - # Same argv-injection concern as ``base_branch`` above — ``branch`` flows - # into ``git fetch origin `` and ``git rev-list origin/..HEAD`` - # in the push handler, so refuse leading dashes and other unsafe ref shapes - # via ``validate_branch_ref`` regardless of upstream callers' own checks. - if branch is not None: - if not isinstance(branch, str): - return make_error("Invalid branch: must be a string") - if len(branch) > 256: - return make_error("Invalid branch: must be 256 characters or fewer") - try: - validate_branch_ref(branch, "branch") - except ValueError as exc: - return make_error(str(exc)) - - # Validate synthetic if provided - if not isinstance(synthetic, bool): - return make_error("Invalid synthetic: must be a boolean") - - # Validate upstream / upstream_model (issue #2769). - # ``upstream`` must be a name the UpstreamRegistry will serve — refuse - # silently routing unknown upstreams to Anthropic. - if not isinstance(upstream, str): - return make_error("Invalid upstream: must be a string") - if not get_upstream_registry().is_known(upstream): - known = ", ".join(sorted(get_upstream_registry().known_upstreams())) - return make_error(f"Invalid upstream: '{upstream}'. Must be one of: {known}") - if upstream_model is not None: - if not isinstance(upstream_model, str): - return make_error("Invalid upstream_model: must be a string") - if not upstream_model: - return make_error("Invalid upstream_model: must be non-empty if provided") - if len(upstream_model) > 256: - return make_error("Invalid upstream_model: must be 256 characters or fewer") - - # Validate worktree_container_id if provided - if worktree_container_id is not None: - if not isinstance(worktree_container_id, str): - return make_error("Invalid worktree_container_id: must be a string") - if not worktree_container_id: - return make_error("Invalid worktree_container_id: must be non-empty if provided") - if len(worktree_container_id) > 256: - return make_error("Invalid worktree_container_id: must be 256 characters or fewer") - if ".." in worktree_container_id or not re.match( - r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$", worktree_container_id - ): - return make_error("Invalid worktree_container_id: contains unsafe characters") - - # Validate local_only_repos if provided - if local_only_repos: - if not isinstance(local_only_repos, list): - return make_error("Invalid local_only_repos: must be a list") - if len(local_only_repos) > 50: - return make_error("Invalid local_only_repos: too many entries") - for repo_name in local_only_repos: - if not isinstance(repo_name, str): - return make_error("Invalid local_only_repos: all items must be strings") - if not repo_name or len(repo_name) > 256: - return make_error("Invalid local_only_repos: repo name must be 1-256 chars") - if ".." in repo_name or "/" in repo_name: - return make_error(f"Invalid local_only_repos: unsafe repo name '{repo_name}'") - - # Step 1: Query visibility for all repos - repo_visibilities = {} - for repo in repos: - repo_info = parse_owner_repo(repo) - if repo_info: - visibility = get_repo_visibility(repo_info.owner, repo_info.repo) - repo_visibilities[repo] = visibility - else: - # Can't parse repo - skip it - logger.warning( - "Could not parse repository for visibility check", - repo=repo, - container_id=container_id, - ) - - # Step 2: Filter repos based on mode - # private mode: include repos with known visibility (private, internal, public). - # Write access is controlled separately by push policy (only private/internal - # repos are writable). The network is locked down — mounting a public repo - # in private mode doesn't grant broader internet access. - # Repos with unknown visibility (None) are excluded (fail-closed). - # public mode: keep only public repos (don't mount private repos on open network) - filtered_repos = [] - for repo, visibility in repo_visibilities.items(): - if mode == "private": - # Private mode: include repos with known visibility — network is - # locked down anyway so mounting a public repo is safe. - # Push policy enforces write restrictions to private/internal repos. - if visibility is None: - # Unknown visibility — repo may not exist or API unreachable. - # Fail closed: don't attempt to mount a repo we can't verify. - logger.warning( - "Unknown visibility for repo, excluding in private mode", - repo=repo, - container_id=container_id, - ) - continue - elif visibility not in ("private", "internal"): - logger.info( - "Including public repo in private mode (network locked down)", - repo=repo, - visibility=visibility, - container_id=container_id, - ) - filtered_repos.append(repo) - else: - # Public mode: only mount public repos - if visibility is None: - # Unknown visibility — can't confirm public, exclude - logger.warning( - "Unknown visibility for repo, excluding in public mode", - repo=repo, - container_id=container_id, - ) - elif visibility == "public": - filtered_repos.append(repo) - else: - logger.debug( - "Excluding non-public repo in public mode", - repo=repo, - visibility=visibility, - container_id=container_id, - ) - - # Step 2b: Include local-only repos in private mode. - # These repos have no GitHub remote so GitHub visibility cannot be checked. - # They are always treated as private: included in private mode, excluded in public mode. - if local_only_repos and mode == "private": - for repo_name in local_only_repos: - filtered_repos.append(repo_name) - logger.info( - "Including local-only repo in private mode", - repo=repo_name, - container_id=container_id, - ) - elif local_only_repos and mode != "private": - logger.debug( - "Excluding local-only repos in public/local mode", - repos=local_only_repos, - mode=mode, - container_id=container_id, - ) - - # Step 3: Create worktrees for filtered repos - worktrees = {} - worktree_errors = [] - first_worktree_path: str | None = None # Gateway-side path for the session's repo context - - # Only initialise the worktree manager when there are repos to process. - # Local-mode sessions (no repos) skip worktree creation entirely, so - # avoid hitting the filesystem for the worktree base directory. - if filtered_repos: - manager = get_worktree_manager() - - for repo in filtered_repos: - # Extract repo name from owner/repo format - if "/" in repo: - repo_name = repo.split("/")[-1] - else: - repo_name = repo - - try: - if worktree_container_id: - # Reuse worktrees created by a prior /api/v1/worktrees/create - # call. Avoids a second concurrent ``git worktree add`` on the - # same bare repo, which races on ``.git/config.lock`` (#1857). - info = manager.lookup_worktree( - repo_name=repo_name, - container_id=worktree_container_id, - ) - else: - # For pipeline sessions, prefer the pipeline's existing worktree - # branch (which contains artifacts from prior agents) over a fresh - # branch from origin/main. This ensures HITL exec sessions can - # see drafts, contracts, and reviews committed by pipeline agents. - # See #1016. - # - # For fresh pipelines (no prior worktree branch), fall back to the - # remote default branch (e.g., origin/main) instead of HEAD. HEAD - # may point to a feature branch in the main repo, which would - # pollute the worktree with commits outside the current phase's - # allowed scope and cause push rejections. See #860. - if pipeline_id: - pipeline_work_branch = f"egg/{pipeline_id}/work" - if _branch_exists_on_remote(manager, repo_name, pipeline_work_branch): - worktree_base_branch = f"origin/{pipeline_work_branch}" - else: - worktree_base_branch = manager.resolve_default_branch(repo_name) - else: - worktree_base_branch = "HEAD" - - info = manager.create_worktree( - repo_name=repo_name, - container_id=container_id, - base_branch=worktree_base_branch, - uid=uid, - gid=gid, - assigned_branch=branch, - repo_slug=repo, - ) - # Capture the first worktree's gateway-side path for the session's repo context - if first_worktree_path is None: - first_worktree_path = str(info.worktree_path) - # Translate container path to host path for egg launcher mount sources - worktrees[repo_name] = translate_to_host_path(str(info.worktree_path)) - except ValueError as e: - worktree_errors.append(f"{repo_name}: {e}") - except RuntimeError as e: - worktree_errors.append(f"{repo_name}: {e}") - except Exception as e: - worktree_errors.append(f"{repo_name}: unexpected error - {e}") - - # If no worktrees could be created, fail - if not worktrees and filtered_repos: - return make_error( - "Failed to create any worktrees", - status_code=500, - details={"errors": worktree_errors}, - ) - - # Step 4: Register session - session_manager = get_session_manager() - token, _session = session_manager.register_session( - container_id=container_id, - container_ip=container_ip, - mode=mode, - phase=phase, - pipeline_id=pipeline_id, - issue_number=issue_number, - pr_number=pr_number, - agent_role=agent_role, - agent_anchor_id=agent_anchor_id, - claude_code_version=claude_code_version, - branch=branch, - base_branch=session_base_branch, - jira_ticket=jira_ticket if isinstance(jira_ticket, str) and jira_ticket else None, - synthetic=synthetic, - upstream=upstream, - upstream_model=upstream_model, - ) - - # Pre-populate the session's repo path so non-pushing sessions (reviewers, - # architects, etc.) can resolve their worktree before any git push. This is - # also set on git push, but pipeline agents that never push would otherwise - # have a None value. - if first_worktree_path is not None: - _session.last_repo_path = first_worktree_path - - # Use the shared pipeline branch for push enforcement. - # session_manager already sets assigned_branch from the `branch` - # request parameter (session_manager.py:560-564). If that wasn't - # provided, fall back to the canonical pipeline branch name. - if pipeline_id and not _session.assigned_branch: - _session.assigned_branch = f"egg/{pipeline_id}/work" - - audit_log( - "session_created", - "session_create", - success=True, - details={ - "container_id": container_id, - "container_ip": container_ip, - "mode": mode, - "phase": phase, - "pipeline_id": pipeline_id, - "issue_number": issue_number, - "pr_number": pr_number, - "agent_role": agent_role, - "filtered_repos": filtered_repos, - "worktree_count": len(worktrees), - "worktree_errors": worktree_errors if worktree_errors else None, - "upstream": upstream, - "upstream_model": upstream_model, - }, - ) - - return make_success( - "Session created", - { - "session_token": token, - "filtered_repos": filtered_repos, - "worktrees": worktrees, - "errors": worktree_errors if worktree_errors else None, - }, - ) - - -def _cleanup_container_worktrees( - container_id: str, -) -> tuple[list[str], list[str]]: - """Clean up all worktrees for a container. - - Returns: - Tuple of (deleted_repo_names, errors). - """ - manager = get_worktree_manager() - worktree_dir = manager.worktree_base / container_id - deleted_worktrees: list[str] = [] - errors: list[str] = [] - if worktree_dir.exists(): - for repo_dir in list(worktree_dir.iterdir()): - if not repo_dir.is_dir(): - continue - repo_name = repo_dir.name - try: - result = manager.remove_worktree( - container_id=container_id, - repo_name=repo_name, - force=True, - ) - if result.success: - deleted_worktrees.append(repo_name) - elif result.error: - errors.append(f"{repo_name}: {result.error}") - else: - errors.append(f"{repo_name}: removal failed") - except Exception as e: - errors.append(f"{repo_name}: unexpected error - {e}") - return deleted_worktrees, errors - - -@app.route("/api/v1/sessions/", methods=["DELETE"]) -@require_launcher_auth -def session_delete(session_token: str) -> tuple[Response, int] | Response: - """ - Delete a session. - - Only the launcher (with launcher_secret) can delete sessions. - Containers CANNOT delete sessions. - - Also cleans up associated worktrees. - - Args: - session_token: The session token to delete - - Auth: Bearer {launcher_secret} - """ - session_manager = get_session_manager() - - # Get session info for worktree cleanup - session = session_manager.get_session(session_token) - container_id = session.container_id if session else None - - # Delete the session - deleted = session_manager.delete_session(session_token) - - if not deleted: - return make_error("Session not found", status_code=404) - - # _capture_and_cleanup_session (called inside delete_session) auto-commits - # the agent's WIP synchronously before returning, so the worktree is safe - # to remove at this point. - - # Clean up worktrees for this container - deleted_worktrees, worktree_errors = ( - _cleanup_container_worktrees(container_id) if container_id else ([], []) - ) - - audit_log( - "session_deleted", - "session_delete", - success=True, - details={ - "container_id": container_id, - "worktrees_deleted": deleted_worktrees, - "errors": worktree_errors if worktree_errors else None, - }, - ) - - return make_success("Session deleted") - - -@app.route("/api/v1/sessions/by-container/", methods=["DELETE"]) -@require_launcher_auth -def session_delete_by_container(container_id: str) -> tuple[Response, int] | Response: - """ - Delete a session by container ID. - - Used by the orchestrator for cleanup when the session token is not available. - - Args: - container_id: The container ID whose session to delete - - Auth: Bearer {launcher_secret} - """ - session_manager = get_session_manager() - deleted = session_manager.delete_session_by_container(container_id) - - if not deleted: - return make_error("Session not found for container", status_code=404) - - # _capture_and_cleanup_session (called inside delete_session_by_container) - # auto-commits the agent's WIP synchronously before returning, so the - # worktree is safe to remove at this point. - - # Clean up worktrees for this container - deleted_worktrees, worktree_errors = _cleanup_container_worktrees(container_id) - - audit_log( - "session_deleted", - "session_delete_by_container", - success=True, - details={ - "container_id": container_id, - "worktrees_deleted": deleted_worktrees, - "errors": worktree_errors if worktree_errors else None, - }, - ) - - return make_success("Session deleted") - - -@app.route("/api/v1/sessions/by-container//heartbeat", methods=["POST"]) -@require_launcher_auth -def session_heartbeat_by_container(container_id: str) -> tuple[Response, int] | Response: - """ - Refresh a session's idle timer by container ID (orchestrator-only path). - - Used by the orchestrator to keep agent sessions alive while their - container is heartbeating on the BRC bus but not making gateway - requests — without this, the idle pruner evicts the session after - EGG_SESSION_IDLE_TIMEOUT_MINUTES even though the agent is still - working (see #2068). - - Auth: Bearer {launcher_secret} - - Returns 404 if no session exists for the container. No per-session - rate limit because the launcher secret already gates access — only - the orchestrator can call this. - """ - session_manager = get_session_manager() - refreshed = session_manager.heartbeat_session_by_container(container_id) - - if not refreshed: - return make_error("Session not found for container", status_code=404) - - return make_success("Heartbeat recorded") - - -@app.route("/api/v1/sessions/", methods=["GET"]) -@require_launcher_auth -def session_get(session_token: str) -> tuple[Response, int] | Response: - """ - Get session information and validate if it exists. - - Args: - session_token: The session token - - Auth: Bearer {launcher_secret} - - Response: - { - "valid": true, - "mode": "private"|"public", - "container_id": "...", - "expires_at": "...", - } - """ - session_manager = get_session_manager() - result = session_manager.validate_session(session_token) - - if not result.valid: - return jsonify({"valid": False, "error": result.error or "Session not found"}), 404 - - return jsonify( - { - "valid": True, - "mode": result.session.mode if result.session else None, - "container_id": result.session.container_id if result.session else None, - "expires_at": result.session.expires_at.isoformat() if result.session else None, - } - ) - - -@app.route("/api/v1/sessions//heartbeat", methods=["POST"]) -@require_launcher_auth -def session_heartbeat(session_token: str) -> tuple[Response, int] | Response: - """ - Explicit session heartbeat to extend TTL. - - Note: Heartbeats are also triggered implicitly on any successful - session-authenticated request. This endpoint exists for edge cases - where long-running operations need TTL extension without git/gh activity. - - Args: - session_token: The session token - - Auth: Bearer {session_token} - - Rate limit: 100 per hour per session - """ - # Validate the session - result = validate_session_for_request(session_token, request.remote_addr) - if not result.valid: - # Record failed lookup for rate limiting - record_failed_lookup(request.remote_addr or "") - return make_error(result.error or "Invalid session", status_code=401) - - # Check heartbeat rate limit (100 per hour per session) - if result.session: - rate_limit = check_heartbeat_rate_limit(result.session.session_token_hash) - if not rate_limit.allowed: - return make_error( - f"Heartbeat rate limit exceeded. Retry after {rate_limit.retry_after_seconds}s", - status_code=429, - ) - - # Session validation already extends TTL, just return success - return make_success( - "Heartbeat recorded", - { - "expires_at": result.session.expires_at.isoformat() if result.session else None, - }, - ) - - -@app.route("/api/v1/sessions/", methods=["PATCH"]) -@require_launcher_auth -def session_update(session_token: str) -> tuple[Response, int] | Response: - """ - Update session container binding (container_id and/or container_ip). - - Used by the orchestrator to bind a session to the real container - after pre-registering with a placeholder ID before container creation. - - Request body: - { - "container_id": "abc123...", # Optional - "container_ip": "172.32.0.10" # Optional - } - - At least one of container_id or container_ip must be provided. - - Args: - session_token: The session token to update - - Auth: Bearer {launcher_secret} - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - container_id = data.get("container_id") - container_ip = data.get("container_ip") - - if not container_id and not container_ip: - return make_error("Must provide container_id and/or container_ip") - - session_manager = get_session_manager() - success = session_manager.update_session( - session_token, - container_id=container_id, - container_ip=container_ip, - ) - - if not success: - return make_error("Session not found or expired", status_code=404) - - audit_log( - "session_container_updated", - "session_update", - success=True, - details={ - "container_id": container_id, - "container_ip": container_ip, - }, - ) - - return make_success( - "Session updated", - { - "container_id": container_id, - "container_ip": container_ip, - }, - ) - - -# Valid SDLC pipeline phases — derived from phase_filter.PipelinePhase to avoid drift. -VALID_PIPELINE_PHASES = frozenset(p.value for p in PipelinePhase) - - -@app.route("/api/v1/sessions//phase", methods=["PATCH"]) -@require_launcher_auth -def session_update_phase(session_token: str) -> tuple[Response, int] | Response: - """ - Update the SDLC pipeline phase for a session. - - This endpoint allows the launcher/workflow to update the phase as - the pipeline progresses. Phase restrictions are enforced by the - gateway for operations like PR creation. - - Request body: - { - "phase": "refine"|"plan"|"implement"|"pr" - } - - Args: - session_token: The session token to update - - Auth: Bearer {launcher_secret} - """ - data = request.get_json() - if not data: - return make_error("Missing request body") - - phase = data.get("phase") - if not phase: - return make_error("Missing phase") - - if phase not in VALID_PIPELINE_PHASES: - return make_error( - f"Invalid phase: {phase}. Must be one of: {', '.join(sorted(VALID_PIPELINE_PHASES))}" - ) - - session_manager = get_session_manager() - success = session_manager.update_phase(session_token, phase) - - if not success: - return make_error("Session not found or expired", status_code=404) - - audit_log( - "session_phase_updated", - "session_update_phase", - success=True, - details={"phase": phase}, - ) - - return make_success("Phase updated", {"phase": phase}) - - -@app.route("/api/v1/repos/visibility", methods=["GET"]) -@require_launcher_auth -def repos_visibility() -> tuple[Response, int] | Response: - """ - Query visibility for multiple repositories. - - Used by launcher for informational queries. For atomic session+worktree - creation, use POST /api/v1/sessions/create instead. - - Query params: - repos: Comma-separated list of owner/repo strings - - Response: - { - "visibilities": { - "owner/repo1": "public", - "owner/repo2": "private", - "owner/repo3": "internal" - } - } - - Auth: Bearer {launcher_secret} - """ - repos_param = request.args.get("repos", "") - if not repos_param: - return make_error("Missing repos query parameter") - - repos = [r.strip() for r in repos_param.split(",") if r.strip()] - if not repos: - return make_error("No valid repos provided") - - visibilities = {} - for repo in repos: - repo_info = parse_owner_repo(repo) - if repo_info: - visibility = get_repo_visibility(repo_info.owner, repo_info.repo) - visibilities[repo] = visibility - else: - visibilities[repo] = None - - return make_success("Visibility queried", {"visibilities": visibilities}) - - -@app.route("/api/v1/sessions", methods=["GET"]) -@require_launcher_auth -def sessions_list() -> tuple[Response, int] | Response: - """ - List all active sessions. - - Auth: Bearer {launcher_secret} - """ - session_manager = get_session_manager() - sessions = session_manager.list_sessions() - return make_success("Sessions listed", {"sessions": sessions}) - - -# ============================================================================= -# Anthropic API Proxy Endpoints -# ============================================================================= - -# Singleton httpx client with connection pooling for Anthropic API -_anthropic_client: httpx.Client | None = None - - -def get_anthropic_client() -> httpx.Client: - """Get or create the singleton Anthropic API client.""" - global _anthropic_client - if _anthropic_client is None: - _anthropic_client = httpx.Client( - base_url="https://api.anthropic.com", # noqa: EGG200 - gateway proxy client, not direct LLM call - timeout=httpx.Timeout(120.0, connect=10.0), - limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), - ) - return _anthropic_client - - -# Headers to block - forward everything else for maximum compatibility -ANTHROPIC_BLOCKED_HEADERS = { - "host", - "content-length", - "transfer-encoding", - "authorization", - "x-api-key", - "connection", -} - - -def _get_forwarded_headers(request_headers: Any) -> dict[str, str]: - """Forward all headers except blocked ones (blocklist approach).""" - return {k: v for k, v in request_headers if k.lower() not in ANTHROPIC_BLOCKED_HEADERS} - - -def _filter_response_headers(headers: Any) -> dict[str, str]: - """Filter response headers for passthrough.""" - # Preserve important headers like x-request-id for debugging - skip = {"content-encoding", "transfer-encoding", "connection"} - return {k: v for k, v in headers.items() if k.lower() not in skip} - - -def _inject_upstream_credentials( - headers: dict[str, str], - upstream: str = "anthropic", -) -> tuple[dict[str, str], tuple[Any, int] | None]: - """ - Inject upstream credentials into headers. - - Dispatches per-upstream so the gateway can carry both Anthropic and - LiteLLM credentials side-by-side (issue #2769 cq-7). For the Anthropic - upstream this is byte-identical to the legacy - ``_inject_anthropic_credentials`` helper — same OAuth/API-key precedence, - same 401 error shape on missing credentials, same client-supplied auth - fall-through. The LiteLLM upstream uses ``x-api-key`` only (no OAuth - path) and has no client-supplied-auth fall-through because Claude Code - never carries a LiteLLM master key. - - An upstream the registry does not serve is rejected with a 502 — it - is never silently treated as Anthropic. - - Args: - headers: Mutable header dict — credential is appended in place. - upstream: ``"anthropic"`` (default — back-compat) or ``"litellm"``. - - Returns: - (headers, None) on success - (headers, error_response_tuple) on failure - caller should return this - """ - # Refuse to silently treat an unknown upstream as Anthropic. Falling - # through to the Anthropic branch produced an observable error-code - # inconsistency — 401 vs 502 for the same invalid input depending on - # unrelated Anthropic-credential state. An unregistered upstream now - # fails closed with a 502, matching the proxy routes' own - # UnknownUpstreamError handling (issue #2769 review). - if not get_upstream_registry().is_known(upstream): - logger.warning( - "Unknown upstream for credential injection, refusing request", - upstream=upstream, - ) - return headers, ( - jsonify( - { - "error": { - "type": "api_error", - "message": f"Unknown upstream '{upstream}'", - } - } - ), - 502, - ) - - if upstream == "litellm": - cred = get_litellm_credentials_manager().get_credential() - if cred: - headers[cred.header_name] = cred.header_value - return headers, None - logger.warning( - "No LiteLLM master key available for proxy request", - upstream=upstream, - ) - return headers, ( - jsonify( - { - "error": { - "type": "authentication_error", - "message": "No LiteLLM credentials available", - } - } - ), - 401, - ) - - # Default: anthropic upstream — preserves the legacy behavior verbatim. - credentials_manager = get_credentials_manager() - cred = credentials_manager.get_credential() - - if cred: - # Credential includes header_name (x-api-key or Authorization) - # and header_value (raw key or "Bearer ") - headers[cred.header_name] = cred.header_value - return headers, None - - # No gateway-managed credentials - check if client sent auth - # This allows OAuth mode where Claude Code manages its own tokens - client_auth = headers.get("Authorization") - client_api_key = headers.get("x-api-key") - if client_auth or client_api_key: - return headers, None - - logger.warning( - "No Anthropic credentials available for proxy request", - has_gateway_cred=False, - has_client_auth=bool(client_auth), - has_client_api_key=bool(client_api_key), - ) - return headers, ( - jsonify( - { - "error": { - "type": "authentication_error", - "message": "No Anthropic credentials available", - } - } - ), - 401, - ) - - -def _inject_anthropic_credentials( - headers: dict[str, str], -) -> tuple[dict[str, str], tuple[Any, int] | None]: - """Back-compat alias delegating to the upstream-aware injector. - - Kept so external test mocks targeting ``_inject_anthropic_credentials`` - continue to work. New code paths should call - ``_inject_upstream_credentials(headers, upstream)`` directly. - """ - return _inject_upstream_credentials(headers, upstream="anthropic") - - -# ============================================================================= -# Routing policy — gateway-as-single-router (issue #2987) -# ============================================================================= -# The gateway is the single LLM router. On top of the spawn-time per-agent -# upstream selection (``session.upstream``), a hot-reloadable, model-keyed -# routing policy adds a proactive ``switchover`` remap and a reactive -# ``fallbacks`` chain. See gateway/routing_policy.py for the schema and the -# fail-open posture. The helpers below resolve a request into an ordered -# list of ``RouteHop``s and prepare a clean (client, headers, body) tuple -# per hop — the credentials are rebuilt from scratch on every hop so a -# fallback never carries the previous upstream's auth header (the bleed -# fix: litellm's ``x-api-key`` and Anthropic-OAuth's ``Authorization`` are -# different header names, so re-injecting into a dirty dict would otherwise -# leak the stale key onto the next upstream). - - -def _extract_wire_model(request_body: bytes) -> str | None: - """Return the request body's ``"model"`` field, or ``None`` on parse miss.""" - try: - model = json.loads(request_body).get("model") - except json.JSONDecodeError, TypeError, AttributeError: - return None - return model if isinstance(model, str) else None - - -def _rewrite_upstream_model(request_body: bytes, model: str) -> bytes: - """Set ``body["model"] = model`` and re-encode; original bytes on parse miss. - - A narrowly-scoped reintroduction of the helper #2832 retired. #2832 - removed the *unconditional* LiteLLM-path rewrite (Claude Code now sends - the wire model directly); this version fires ONLY when a routing-policy - hop names an explicit target model, so the no-policy path is - byte-identical — the body is never touched unless a switchover or - fallback hop specifies a ``model``. - """ - try: - body = json.loads(request_body) - except json.JSONDecodeError, TypeError: - return request_body - if not isinstance(body, dict): - return request_body - body["model"] = model - return json.dumps(body).encode() - - -def _resolve_route_chain( - session_upstream: str, - request_body: bytes, -) -> tuple[list[RouteHop], Any]: - """Resolve a request into an ordered ``[RouteHop, ...]`` chain + triggers. - - Hop 0 is the *initial* route: a proactive ``switchover`` remap for the - wire model if one is configured, else the spawn-time ``session_upstream`` - (no model rewrite — byte-identical to today). Hops 1..N are the reactive - fallback chain for the wire model, in order. With an empty policy the - chain is a single hop on ``session_upstream`` and behavior is - byte-identical to the pre-#2987 path. - """ - policy = get_routing_policy_manager().get_policy() - wire_model = _extract_wire_model(request_body) - - switch = policy.switchover_for(wire_model) - initial = switch if switch is not None else RouteHop(upstream=session_upstream, model=None) - chain = [initial, *policy.fallback_chain_for(wire_model)] - return chain, policy.triggers - - -class _PreparedHop(NamedTuple): - """A hop ready to send: resolved client, freshly-injected headers, body.""" - - client: Any - headers: dict[str, str] - body: bytes - - -class _HopPrepError(Exception): - """Raised by ``_prepare_hop`` when a hop cannot be prepared. - - Carries the ``(Response, status)`` tuple the proxy route should return if - this is the last hop (the caller advances to a fallback instead when one - exists). Modeling the failure as an exception — rather than an optional - field in the return tuple — lets the success path be a non-optional - ``_PreparedHop`` that type-checks cleanly at the call sites. - """ - - def __init__(self, response: tuple[Any, int]) -> None: - super().__init__("hop preparation failed") - self.response = response - - -def _sanitize_attribution_value(value: str) -> str: - """Constrain a session field to a safe HTTP header value. - - The values are orchestrator-authoritative (set via the launcher-secret - ``register_session``), so this is belt-and-braces: drop anything outside - printable ASCII (CR/LF would otherwise allow header injection) and cap - the length so a pathological value cannot bloat every upstream request. - """ - return "".join(ch for ch in value if 32 <= ord(ch) < 127)[:256] - - -def _with_attribution_headers(headers: dict[str, str], session: Any) -> dict[str, str]: - """Stamp gateway-authoritative ``x-egg-*`` attribution onto a non-Anthropic hop. - - The egg-litellm ``cost_callback`` keys its per-session cost/cache log - lines on ``x-claude-code-session-id``, which maps to a role only by hand - cross-referencing agent completion logs (issue #3175). The gateway - resolves the full ``Session`` — ``pipeline_id`` / ``agent_role`` / - ``phase`` — on every ``/v1/messages`` call anyway, so it stamps them here - and the callback logs spend per role directly. - - Any client-supplied ``x-egg-*`` header is dropped first: the sandbox - controls its own request headers (e.g. via ``ANTHROPIC_CUSTOM_HEADERS``), - so agent-supplied values are untrusted and must never masquerade as - attribution. Applied only to non-Anthropic hops — the Claude path stays - byte-identical. - """ - headers = {k: v for k, v in headers.items() if not k.lower().startswith("x-egg-")} - if session is None: - return headers - for header, value in ( - ("x-egg-pipeline-id", session.pipeline_id), - ("x-egg-agent-role", session.agent_role), - ("x-egg-phase", session.phase), - ): - if value: - sanitized = _sanitize_attribution_value(str(value)) - # A value of only control chars sanitizes to "" — don't stamp an - # empty-valued header (the callback would coerce it to None anyway). - if sanitized: - headers[header] = sanitized - return headers - - -def _prepare_hop( - hop: RouteHop, - request_headers: Any, - request_body: bytes, - session: Any = None, -) -> _PreparedHop: - """Build the (client, headers, body) for one hop. - - Headers are rebuilt with ``_get_forwarded_headers`` from the *original* - request headers on every call, then this hop's credential is injected — - so a fallback hop never inherits the previous upstream's auth header. - Non-Anthropic hops additionally carry ``x-egg-*`` attribution headers - derived from ``session`` (issue #3175); see ``_with_attribution_headers``. - Raises ``_HopPrepError`` (carrying the error response) on a credential or - unknown-upstream failure for this hop; the caller decides whether to - advance to a fallback or surface it. - """ - headers = _get_forwarded_headers(request_headers) - headers, cred_error = _inject_upstream_credentials(headers, upstream=hop.upstream) - if cred_error: - raise _HopPrepError(cred_error) - if hop.upstream != "anthropic": - headers = _with_attribution_headers(headers, session) - - if hop.upstream == "anthropic": - client = get_anthropic_client() - else: - try: - client, _ = get_upstream_registry().get(hop.upstream) - except UnknownUpstreamError: - logger.warning("Unknown upstream on routing hop, refusing", upstream=hop.upstream) - raise _HopPrepError( - ( - jsonify( - { - "error": { - "type": "api_error", - "message": f"Unknown upstream '{hop.upstream}'", - } - } - ), - 502, - ) - ) from None - - body = _rewrite_upstream_model(request_body, hop.model) if hop.model else request_body - return _PreparedHop(client=client, headers=headers, body=body) - - -def _classify_route_status( - status: int, - triggers: Any, - same_hop_attempts: int, - is_last_hop: bool, -) -> str: - """Decide what to do with an upstream's status: retry the same hop, - advance to the next hop, or accept the response. - - Same-hop retry takes precedence while the budget remains (so a code that - is in *both* ``retry_same_on`` and ``advance_on`` retries first, then - escalates). ``advance`` only fires when a fallback hop exists. - """ - if status in triggers.retry_same_on and same_hop_attempts < triggers.retry_same_max: - return "retry_same" - if status in triggers.advance_on and not is_last_hop: - return "advance" - return "accept" - - -# Transport-level errors that mean "this upstream did not produce a usable -# HTTP response" — eligible for the same-hop retry (#1907) and, when a -# fallback hop exists, for advancing the chain (#2987). -_UPSTREAM_TRANSPORT_ERRORS = ( - httpx.ReadError, - httpx.RemoteProtocolError, - httpx.ConnectError, - httpx.TimeoutException, -) - - -def _close_quietly(resp: Any) -> None: - """Close an httpx streaming response, swallowing errors. - - Used to release a discarded upstream connection when the routing loop - retries the same hop or advances to a fallback — a streaming response we - are not going to forward must be closed or its connection leaks. - """ - try: - resp.close() - except Exception: - pass - - -def _send_and_prime( - client: Any, - headers: dict[str, str], - body: bytes, -) -> tuple[Any, Any, bytes | None]: - """Send the upstream request and pre-fetch the first chunk. - - Returns ``(upstream_response, iterator, first_chunk)`` where - ``first_chunk`` is ``None`` if the upstream returned an empty body. - Raises a transport error (``httpx.ReadError`` / ``RemoteProtocolError`` / - ``ConnectError`` / ``TimeoutException``) if the connection fails during - ``send()`` or the first ``iter_bytes()`` call — callers use that signal - to retry the same hop or advance to a fallback (issues #1907, #2987). - """ - http_req = client.build_request("POST", "/v1/messages", headers=headers, content=body) - upstream_resp = client.send(http_req, stream=True) - try: - iterator = upstream_resp.iter_bytes() - try: - first = next(iterator) - except StopIteration: - first = None - return upstream_resp, iterator, first - except BaseException: - # Close the failed upstream so the caller's retry can open a fresh - # connection without leaking the old one. Broad catch ensures - # cleanup on *any* exception from iter_bytes() / next(). - try: - upstream_resp.close() - except Exception: - pass - raise - - -def _attempt_hop_streaming( - client: Any, - headers: dict[str, str], - body: bytes, - *, - container_id: str | None, -) -> tuple[Any, Any, bytes | None]: - """One streaming hop with the #1907 pre-stream transport-reset retry. - - Retries the *same* upstream once if the connection resets before any byte - is forwarded downstream, then raises on the second failure. The - cross-hop routing loop turns that raise into an advance-or-surface - decision (#2987). - """ - for attempt in range(2): - try: - return _send_and_prime(client, headers, body) - except _UPSTREAM_TRANSPORT_ERRORS as reset_err: - if attempt == 0 and isinstance(reset_err, (httpx.ReadError, httpx.RemoteProtocolError)): - logger.warning( - "Upstream connection reset before any byte was forwarded; " - "retrying same upstream once", - container_id=container_id, - error=str(reset_err), - ) - continue - # Connect/timeout failures (and the exhausted reset retry) are not - # retried in place — the routing loop advances to a fallback hop - # if one exists, else re-raises to the outer 502/504 handler. - raise - # Unreachable: ``range(2)`` always returns on success or raises on the - # second attempt. Present so the type checker sees no fall-through path. - raise AssertionError("unreachable") # pragma: no cover - - -# Tools blocked in private mode to prevent data exfiltration -# These tools route through Anthropic's infrastructure, bypassing container network controls -# See PR #686 security findings and PR #702 analysis -BLOCKED_TOOLS_PRIVATE_MODE = {"web_search", "WebSearch", "web_fetch", "WebFetch"} - - -def _resolve_proxy_session( - request_headers: Any, - remote_addr: str | None, -) -> tuple[Any, tuple[Response, int] | None]: - """ - Resolve the session for a ``/v1/messages`` (or ``/count_tokens``) proxy - request. - - Order of resolution (issue #2829): - - 1. **Token-keyed.** Extract the session token from ``x-api-key`` / - ``Authorization`` if the value carries the egg placeholder - envelope. The orchestrator wraps the session token in this - placeholder so Claude Code's local OAuth-token format check - passes while the gateway can still identify the session. This - is the load-bearing path for agent traffic. - 2. **IP-keyed.** When the placeholder is absent, fall back to - source-IP lookup. Pod IPs are ephemeral in k8s so this is a - compat path for non-agent clients only (health probes, host - dev tools). The slice-1 "no session → anthropic" invariant for - non-agent probes is preserved. - - Defense-in-depth: when the placeholder IS present but the session - lookup misses, return a 502. Silently falling through to the - anthropic default would silently mis-route per-agent inference - (the routing bug) and disable private-mode web-tool filtering (the - filter-bypass bug). Both were invisible at runtime before the fix. - - Side effect: ``get_session`` delegates to ``validate_session``, - which calls ``session.extend_ttl`` on every successful lookup. The - proxy is therefore no longer a read-only consumer of the session — - each ``/v1/messages`` (or ``/count_tokens``) call bumps the - session's ``last_seen``, so active agent inference keeps the - session alive without a separate heartbeat. The legacy - ``get_session_by_ip`` fallback is still read-only. - - Returns ``(session_or_none, error_response_or_none)``. On error the - caller MUST return the error response; on success ``session`` may - be ``None`` for non-placeholder probes and the caller falls back - to the anthropic default. - """ - raw_auth = request_headers.get("x-api-key") or request_headers.get("Authorization") - placeholder_token = _session_token_from_placeholder(raw_auth) - session_manager = get_session_manager() - - if placeholder_token: - session = session_manager.get_session(placeholder_token) - if session is None: - # ``validate_session`` (called via ``get_session``) already - # logs ``event_type=session_auth_failed`` with the token - # hash; emitting a second warning here would double-count - # any "auth failure rate" alert keyed off the first event. - # Caller's ``remote_addr`` shows up in standard request - # logs for correlation. - return None, ( - jsonify( - { - "error": { - "type": "api_error", - "message": "Unknown or expired session", - } - } - ), - 502, - ) - return session, None - - # Non-agent probe (no placeholder). Try IP-keyed lookup for - # backwards compatibility but failure here is non-fatal — the - # caller falls through to the anthropic default. - return session_manager.get_session_by_ip(remote_addr or ""), None - - -def _filter_blocked_tools(request_body: bytes, session_mode: str | None) -> bytes: - """ - Remove blocked tools from API request when in private mode. - - In private mode, WebSearch and WebFetch bypass container network controls - because they're processed by Anthropic's infrastructure. This creates a - data exfiltration risk where a compromised agent could encode sensitive - data in search queries. - - By filtering these tools at the gateway, we enforce the restriction at - the infrastructure level where the container cannot bypass it. - - Args: - request_body: Raw JSON request body - session_mode: The session's mode ("private" or "public"), or None - - Returns: - Modified request body with blocked tools removed (if in private mode), - or original body unchanged (if in public mode or on parse error) - """ - if session_mode != "private": - return request_body - - try: - body = json.loads(request_body) - if "tools" not in body: - return request_body - - original_tools = body["tools"] - filtered_tools = [ - t for t in original_tools if t.get("name") not in BLOCKED_TOOLS_PRIVATE_MODE - ] - - removed_count = len(original_tools) - len(filtered_tools) - if removed_count > 0: - removed_names = [ - t.get("name") for t in original_tools if t.get("name") in BLOCKED_TOOLS_PRIVATE_MODE - ] - logger.info( - "Filtered blocked tools in private mode", - removed_count=removed_count, - removed_tools=removed_names, - ) - body["tools"] = filtered_tools - return json.dumps(body).encode() - - except (json.JSONDecodeError, TypeError) as e: - logger.warning("Failed to parse request body for tool filtering", error=str(e)) - - return request_body - - -def _is_streaming_request(request_body: bytes) -> bool: - """ - Check if request body indicates streaming mode. - - Parses JSON properly to avoid false positives from byte string matching. - """ - try: - body_json = json.loads(request_body) - return body_json.get("stream", False) is True - except json.JSONDecodeError, TypeError: - return False - - -@app.route("/v1/messages", methods=["POST"]) -def proxy_anthropic_messages() -> tuple[Response, int] | Response: - """ - Proxy messages API with credential injection and streaming support. - - This endpoint allows Claude Code to use ANTHROPIC_BASE_URL to route - API traffic through the gateway for credential injection. - - Session lookup is token-keyed via a placeholder embedded in the - ``x-api-key`` header (issue #2829). The orchestrator wraps the - session token in ``sk-ant-oat01-PROXY-INJECTED-egg-session-`` - so Claude Code's local format check passes; the gateway extracts - the token and looks the session up. Non-agent probes (no - placeholder) keep the legacy IP-keyed compat path. - """ - session, lookup_error = _resolve_proxy_session(request.headers, request.remote_addr) - if lookup_error: - return lookup_error - session_mode = session.mode if session else None - container_id = session.container_id if session else None - # Resolve per-session upstream (issue #2769). With no session, default to - # "anthropic" so today's Claude path is byte-identical when an unrelated - # client probes /v1/messages without first registering a session. - upstream_name = session.upstream if session else "anthropic" - - request_body = request.get_data() - request_body = _filter_blocked_tools( - request_body, session_mode - ) # Remove web tools in private mode - # Per #2832, Claude Code on the LiteLLM path sends the upstream model - # name on the wire directly (via ANTHROPIC_CUSTOM_MODEL_OPTION). The - # gateway only rewrites ``"model"`` when a routing-policy hop names an - # explicit target (see ``_prepare_hop`` / ``_rewrite_upstream_model``). - is_streaming = _is_streaming_request(request_body) - - # Resolve the routing chain (issue #2987). Hop 0 is the proactive - # ``switchover`` remap for this wire model, or — with no switchover entry - # — the spawn-time ``session.upstream``. Hops 1..N are the reactive - # ``fallbacks`` chain for the wire model. With an empty routing policy the - # chain is a single hop on ``session.upstream`` and every step below is - # byte-identical to the pre-#2987 path. ``triggers`` decides which status - # codes retry the same upstream vs advance to the next hop; credentials - # are rebuilt per hop inside ``_prepare_hop`` so a fallback never carries - # the previous upstream's auth header. - chain, triggers = _resolve_route_chain(upstream_name, request_body) - # The upstream actually serving the request, for error/log context. The - # outer ``except`` handlers below read this so a fallback hop's failure - # is attributed to the hop that failed, not hop 0. - serving_upstream = chain[0].upstream - - try: - if is_streaming: - # Stream SSE response using httpx's send() with stream=True - # This gives us direct control over the response lifecycle. - # - # Resilience strategy (see #1907, extended for routing in #2987): - # (A) Pre-stream retry — if the upstream TCP connection resets - # before any byte has been yielded downstream, open a fresh - # upstream connection and retry the same hop once - # (``_attempt_hop_streaming``). The downstream SDK never - # sees the error. - # (B) Cross-hop fallback — if the primed upstream returns a - # trigger status (quota / opt-in 5xx) or a transport - # failure that survives (A), and a fallback hop exists, - # advance to it. All of this happens in the *pre-stream* - # window, before any byte is forwarded downstream. - # (C) Mid-stream synthetic error — once bytes have flowed, a - # reset can no longer fall back; emit a well-formed SSE - # ``event: error`` frame and close cleanly so the SDK fails - # gracefully instead of dying on a truncated socket. - # - # Full stream resumption is not attempted — Anthropic's API has - # no resume tokens, and the partial generation on the wire is - # orphaned on any mid-stream reset regardless. - upstream: Any = None - primed_iterator: Any = None - first_chunk: bytes | None = None - hop_idx = 0 - same_hop_attempts = 0 - while True: - hop = chain[hop_idx] - is_last_hop = hop_idx == len(chain) - 1 - serving_upstream = hop.upstream - try: - prepared = _prepare_hop(hop, request.headers, request_body, session=session) - except _HopPrepError as prep_err: - if is_last_hop: - return prep_err.response - logger.warning( - "Routing hop failed credential/upstream prep; advancing", - upstream=hop.upstream, - next_upstream=chain[hop_idx + 1].upstream, - ) - hop_idx += 1 - same_hop_attempts = 0 - continue - try: - upstream, primed_iterator, first_chunk = _attempt_hop_streaming( - prepared.client, prepared.headers, prepared.body, container_id=container_id - ) - except _UPSTREAM_TRANSPORT_ERRORS as hop_err: - if is_last_hop: - # Last hop — surface via the outer 502/504 handlers, - # preserving today's error contract. - raise - logger.warning( - "Routing hop transport failure; advancing to fallback", - upstream=hop.upstream, - next_upstream=chain[hop_idx + 1].upstream, - error=str(hop_err), - ) - hop_idx += 1 - same_hop_attempts = 0 - continue - - decision = _classify_route_status( - upstream.status_code, triggers, same_hop_attempts, is_last_hop - ) - if decision == "retry_same": - same_hop_attempts += 1 - logger.warning( - "Upstream returned a retryable status; retrying same upstream", - upstream=hop.upstream, - status=upstream.status_code, - attempt=same_hop_attempts, - ) - _close_quietly(upstream) - continue - if decision == "advance": - logger.warning( - "Upstream returned a fallback-trigger status; advancing", - upstream=hop.upstream, - status=upstream.status_code, - next_upstream=chain[hop_idx + 1].upstream, - ) - _close_quietly(upstream) - hop_idx += 1 - same_hop_attempts = 0 - continue - break # accept this hop's response - - response_headers = _filter_response_headers(upstream.headers) - # Forward actual Content-Type from upstream (usually text/event-stream) - content_type = upstream.headers.get("content-type", "text/event-stream") - - def generate() -> Any: - try: - if first_chunk is not None: - yield first_chunk - yield from primed_iterator - except (httpx.ReadError, httpx.RemoteProtocolError) as mid_err: - # Mid-stream reset: emit a synthetic SSE `error` frame so - # the downstream SDK treats this as a clean API error - # instead of a truncated socket. The frame shape matches - # Anthropic's documented error event. - logger.warning( - "Upstream stream reset mid-response; emitting synthetic SSE error frame", - upstream=serving_upstream, - container_id=container_id, - error=str(mid_err), - ) - error_payload = { - "type": "error", - "error": { - "type": "api_error", - "message": "upstream connection reset", - }, - } - error_frame = ( - b"event: error\ndata: " - + json.dumps(error_payload).encode("utf-8") - + b"\n\n" - ) - yield error_frame - finally: - upstream.close() - - return Response( - stream_with_context(generate()), - status=upstream.status_code, - headers=response_headers, - content_type=content_type, - ) - else: - # Non-streaming: walk the same routing chain without priming. - # Status-based retry/advance applies; a transport failure on a - # non-last hop advances (else surfaces via the outer handlers, - # preserving today's 502/504 contract). - response: Any = None - hop_idx = 0 - same_hop_attempts = 0 - while True: - hop = chain[hop_idx] - is_last_hop = hop_idx == len(chain) - 1 - serving_upstream = hop.upstream - try: - prepared = _prepare_hop(hop, request.headers, request_body, session=session) - except _HopPrepError as prep_err: - if is_last_hop: - return prep_err.response - hop_idx += 1 - same_hop_attempts = 0 - continue - try: - response = prepared.client.post( - "/v1/messages", headers=prepared.headers, content=prepared.body - ) - except _UPSTREAM_TRANSPORT_ERRORS: - if is_last_hop: - raise - hop_idx += 1 - same_hop_attempts = 0 - continue - - decision = _classify_route_status( - response.status_code, triggers, same_hop_attempts, is_last_hop - ) - if decision == "retry_same": - same_hop_attempts += 1 - continue - if decision == "advance": - hop_idx += 1 - same_hop_attempts = 0 - continue - break # accept - - return Response( - response.content, - status=response.status_code, - headers=_filter_response_headers(response.headers), - ) - - except httpx.ConnectError as e: - logger.error("Upstream connection failed", upstream=serving_upstream, error=str(e)) - return jsonify( - { - "error": { - "type": "api_error", - "message": f"Failed to connect to {serving_upstream} upstream: {e}", - } - } - ), 502 - - except httpx.TimeoutException as e: - logger.error("Upstream request timed out", upstream=serving_upstream, error=str(e)) - return jsonify( - { - "error": { - "type": "api_error", - "message": f"{serving_upstream} upstream request timed out: {e}", - } - } - ), 504 - - except Exception as e: - logger.exception("Upstream proxy error", upstream=serving_upstream) - return jsonify( - { - "error": { - "type": "api_error", - "message": f"{serving_upstream} upstream proxy error: {e}", - } - } - ), 502 - - -@app.route("/v1/messages/count_tokens", methods=["POST"]) -def proxy_count_tokens() -> tuple[Response, int] | Response: - """ - Proxy token counting API (non-streaming). - - This endpoint allows Claude Code to use ANTHROPIC_BASE_URL to route - token counting requests through the gateway. - """ - # Mirror the per-session lookup used by proxy_anthropic_messages so - # count_tokens and messages always agree on which backend serves a - # given agent (issues #2769, #2829). - session, lookup_error = _resolve_proxy_session(request.headers, request.remote_addr) - if lookup_error: - return lookup_error - upstream_name = session.upstream if session else "anthropic" - - count_tokens_body = request.get_data() - - # Honor the proactive ``switchover`` remap so token-counting hits the - # same backend (and model) that messages will use (issue #2987). The - # reactive fallback chain is intentionally NOT walked here — token - # counting is an informational pre-flight, not load-bearing inference, - # so a quota miss surfaces rather than escalating. We take only hop 0 of - # the resolved chain; ``_prepare_hop`` rebuilds clean headers + applies - # the optional model rewrite for that hop. - chain, _triggers = _resolve_route_chain(upstream_name, count_tokens_body) - initial_hop = chain[0] - serving_upstream = initial_hop.upstream - try: - prepared = _prepare_hop(initial_hop, request.headers, count_tokens_body, session=session) - except _HopPrepError as prep_err: - return prep_err.response - - try: - response = prepared.client.post( - "/v1/messages/count_tokens", - headers=prepared.headers, - content=prepared.body, - ) - return Response( - response.content, - status=response.status_code, - headers=_filter_response_headers(response.headers), - ) - - except httpx.ConnectError as e: - logger.error("Upstream connection failed", upstream=serving_upstream, error=str(e)) - return jsonify( - { - "error": { - "type": "api_error", - "message": f"Failed to connect to {serving_upstream} upstream: {e}", - } - } - ), 502 - - except httpx.TimeoutException as e: - logger.error("Upstream request timed out", upstream=serving_upstream, error=str(e)) - return jsonify( - { - "error": { - "type": "api_error", - "message": f"{serving_upstream} upstream request timed out: {e}", - } - } - ), 504 - - except Exception as e: - logger.exception("Upstream proxy error", upstream=serving_upstream) - return jsonify( - { - "error": { - "type": "api_error", - "message": f"{serving_upstream} upstream proxy error: {e}", - } - } - ), 502 - - -def _run_health_server(host: str, port: int) -> None: - """Run a dedicated lightweight HTTP server for health checks. - - This server runs on a separate port from the main Waitress thread pool, - ensuring health checks are never blocked by long-running API requests - (e.g., synchronous git operations holding Waitress threads). - """ - from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - - class HealthHandler(BaseHTTPRequestHandler): - def do_GET(self) -> None: # noqa: N802 - if self.path != "/api/v1/health": - self.send_response(404) - self.end_headers() - return - - # Lightweight health check for Docker liveness probes. - # Note: is_token_valid() can block during token refresh (up to 30s - # synchronous HTTP call to GitHub). ThreadingHTTPServer ensures a - # slow refresh doesn't block concurrent health check requests. - # The full health endpoint on the main port still does - # orchestrator/squid process checks for detailed diagnostics. - try: - github = get_github_client() - token_valid = github.is_token_valid() - except Exception: - token_valid = False - - try: - get_launcher_secret() - launcher_ok = True - except Exception: - launcher_ok = False - - # Quick squid port check - squid_listening = False - try: - with socket.create_connection(("127.0.0.1", 3129), timeout=2): - squid_listening = True - except OSError: - pass - - is_healthy = token_valid and launcher_ok and squid_listening - body = json.dumps( - { - "status": "healthy" if is_healthy else "degraded", - "github_token_valid": token_valid, - "auth_configured": launcher_ok, - "squid_proxy": {"listening": squid_listening}, - "service": "gateway", - } - ).encode() - - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, format: str, *args: Any) -> None: - # Suppress default stderr logging for health checks - pass - - server = ThreadingHTTPServer((host, port), HealthHandler) - server.serve_forever() - - -def main() -> None: - """Run the gateway server.""" - # Safety check: refuse to run as root to prevent permission issues - # When the gateway runs as root, git objects are created with root:root ownership, - # which breaks git operations on the host (permission denied on .git/objects). - if os.getuid() == 0: - print( - "ERROR: gateway must not run as root.\n" - "\n" - "Running as root causes git objects to be created with root:root ownership,\n" - "which breaks git operations on the host with 'permission denied' errors.\n" - "\n" - "To fix this:\n" - " 1. Check the service file path in gateway.service\n" - " 2. Ensure the gateway is started via 'egg' or 'bin/egg-deploy up'\n" - " 3. Restart the gateway and try again\n" - " 4. Verify the gateway is running as your user: ps aux | grep gateway\n" - "\n" - "If .git/objects already has root-owned files, fix with:\n" - " sudo chown -R $(id -u):$(id -g) ~/repos/*/.git", - file=sys.stderr, - ) - sys.exit(1) - - parser = argparse.ArgumentParser(description="Gateway Sidecar REST API") - parser.add_argument( - "--host", - default=DEFAULT_HOST, - help=f"Host to listen on (default: {DEFAULT_HOST})", - ) - parser.add_argument( - "--port", - type=int, - default=DEFAULT_PORT, - help=f"Port to listen on (default: {DEFAULT_PORT})", - ) - parser.add_argument( - "--debug", - action="store_true", - help="Enable debug mode", - ) - parser.add_argument( - "--threads", - type=int, - default=DEFAULT_THREADS, - help=f"Waitress thread pool size (default: {DEFAULT_THREADS})", - ) - parser.add_argument( - "--health-port", - type=int, - default=HEALTH_CHECK_PORT, - help=f"Dedicated health check port (default: {HEALTH_CHECK_PORT})", - ) - - args = parser.parse_args() - - # Initialize token refresher for in-memory token management. - # Retry with backoff on transient failures (e.g. DNS not available at startup). - # Without a GitHub token the gateway can't serve its purpose, so exit if - # initialization never succeeds. - token_init_timeout = int(os.environ.get("EGG_TOKEN_INIT_TIMEOUT", "120")) - try: - from token_refresher import ( - initialize_token_refresher, - is_token_refresher_permanently_failed, - ) - - refresher = None - start_time = time.time() - attempt = 0 - while True: - attempt += 1 - refresher = initialize_token_refresher() - if refresher: - logger.info("Token refresher initialized (in-memory token refresh enabled)") - break - - # Permanent failures (missing credentials/key file) won't resolve - # on retry — exit immediately instead of waiting for the timeout. - if is_token_refresher_permanently_failed(): - logger.warning("Token refresher not configured - GitHub operations will fail") - break - - elapsed = time.time() - start_time - if elapsed >= token_init_timeout: - logger.error( - "Token refresher failed to initialize after timeout — exiting", - timeout_seconds=token_init_timeout, - attempts=attempt, - ) - sys.exit(1) - - backoff = min(5 * (2 ** (attempt - 1)), 30) - remaining = token_init_timeout - elapsed - wait = min(backoff, remaining) - logger.warning( - "Token refresher not ready, retrying", - attempt=attempt, - retry_in_seconds=wait, - elapsed_seconds=round(elapsed, 1), - timeout_seconds=token_init_timeout, - ) - time.sleep(wait) - except ImportError: - logger.error("Token refresher module not available - GitHub operations will fail") - sys.exit(1) - except Exception as e: - logger.error( - "Token refresher initialization failed unexpectedly", - error=str(e), - error_type=type(e).__name__, - ) - sys.exit(1) - - # Initialize reviewer token refresher (optional — for posting reviews with - # approve/request-changes using a separate GitHub App identity). - # Reviewer is optional so we don't retry or block startup. - try: - from token_refresher import initialize_reviewer_token_refresher - - reviewer_refresher = initialize_reviewer_token_refresher() - if reviewer_refresher: - logger.info("Reviewer token refresher initialized") - else: - logger.debug("Reviewer token refresher not configured (optional)") - except ImportError: - pass # Already logged above - except Exception as e: - logger.warning("Reviewer token refresher initialization failed", error=str(e)) - - # Validate user mode config if configured - github = get_github_client() - is_valid, validation_msg = github.validate_user_mode_config() - if not is_valid: - logger.warning("User mode config validation failed", reason=validation_msg) - else: - logger.info("User mode config", status=validation_msg) - - # Load sessions BEFORE worktree cleanup so we know which containers are active. - # After a gateway restart, Docker CLI may not be available inside the container, - # so we derive the active container set from persisted sessions instead. - # - # Each session contributes its own ``container_id`` plus the per-agent - # and pipeline-level worktree anchor IDs ({pipeline_id}-{role} and - # {pipeline_id}). Without those derived anchors, cleanup would treat - # every live pipeline's per-agent worktree as orphaned because the - # on-disk dir name never matches the session container_id (#1874). - active_container_ids: set[str] = set() - try: - session_manager = get_session_manager() - pruned = session_manager.prune_expired_sessions() - if pruned > 0: - logger.info(f"Startup session cleanup pruned {pruned} expired session(s)") - # Extract active container IDs from surviving sessions, plus the - # per-agent/pipeline worktree anchors the orchestrator assigns. - sessions = session_manager.list_sessions() - active_container_ids |= _container_ids_from_sessions(sessions) - if active_container_ids: - logger.info( - "Active containers from sessions", - count=len(active_container_ids), - ) - except Exception as e: - logger.warning("Startup session cleanup failed", error=str(e)) - - # Also check Docker directly as safety net — sessions may be - # pruned but containers still running. - try: - docker_containers = get_active_docker_containers() - active_container_ids |= docker_containers - except Exception as e: - logger.warning("Could not query Docker containers", error=str(e)) - - # Clean up orphaned worktrees in a background thread so it doesn't block - # the Waitress thread pool at startup. Worktree cleanup involves synchronous - # git operations that can hold threads for seconds each, and with many - # orphaned sessions this was exhausting the thread pool before the gateway - # could serve any requests. See: https://github.com/jwbron/egg/issues/1400 - def _background_worktree_cleanup() -> None: - try: - # Container liveness alone cannot distinguish a crashed leftover - # from a pipeline parked at a HITL gate (no containers, no - # sessions — that's its normal state). Ask the orchestrator which - # pipelines are live before sweeping; on a redeploy it may still - # be booting, so poll up to the configured deadline. If it never - # answers, startup_cleanup skips the sweep (fail-safe) — see - # #3070, where a blind sweep with active_containers=0 deleted a - # parked pipeline's worktree, contract, and branches. - active_pipeline_ids = wait_for_active_pipeline_ids() - orphans_removed = startup_cleanup( - active_containers=active_container_ids, - session_manager=get_session_manager(), - active_pipeline_ids=active_pipeline_ids, - ) - if orphans_removed > 0: - logger.info(f"Startup cleanup removed {orphans_removed} orphaned worktree(s)") - except Exception as e: - logger.warning("Startup worktree cleanup failed", error=str(e)) - - cleanup_thread = threading.Thread( - target=_background_worktree_cleanup, - name="startup-worktree-cleanup", - daemon=True, - ) - cleanup_thread.start() - - # Start background session pruner so stale entries don't accumulate across - # restarts. Without this, sessions for dead containers survive until their - # 24h TTL lapses and are reloaded on every gateway restart (#1884). - try: - prune_interval = max(1, int(os.environ.get("EGG_SESSION_CLEANUP_INTERVAL_MINUTES", "15"))) - idle_timeout = max(5, int(os.environ.get("EGG_SESSION_IDLE_TIMEOUT_MINUTES", "60"))) - get_session_manager().start_background_pruner( - interval_minutes=prune_interval, - idle_timeout_minutes=idle_timeout, - ) - except Exception as e: - logger.warning("Failed to start session background pruner", error=str(e)) - - # Ensure launcher secret is configured - fail startup if not - try: - get_launcher_secret() - except LauncherSecretNotConfiguredError as e: - logger.error("Startup failed: launcher secret not configured", error=str(e)) - sys.exit(1) - - # Under k8s the compose-era default hostname "egg-orchestrator" does - # not resolve, so falling back to it produces cryptic "Orchestrator - # unreachable" errors on the agent side mid-pipeline. Fail startup - # instead so the misconfiguration is visible at deploy time (#1803). - if os.environ.get("KUBERNETES_SERVICE_HOST") and not os.environ.get("EGG_ORCHESTRATOR_URL"): - logger.error( - "Startup failed: EGG_ORCHESTRATOR_URL must be set when running in Kubernetes. " - "Set it on the gateway Deployment, e.g. " - "http://orchestrator.egg-system.svc.cluster.local:9849" - ) - sys.exit(1) - - # Register SIGHUP handler for config reload. - # Usage: docker kill -s HUP egg-gateway - def _handle_sighup(signum: int, frame: Any) -> None: - _reload_all_config() - - signal.signal(signal.SIGHUP, _handle_sighup) - - # Register SIGTERM handler for graceful shutdown. - # When Docker sends SIGTERM, delay for 5s to let in-flight session cleanup - # requests complete before exiting. This prevents the race condition where - # the gateway becomes unreachable before the launcher's cleanup hook runs. - # NOTE: This is a delay, not a true drain — waitress continues accepting new - # requests during the sleep. If a new long-running request starts during this - # window, it will be killed when sys.exit(0) fires. For our use case (Docker - # stop), this is acceptable since the only in-flight requests at shutdown are - # short-lived session cleanup calls. - def _handle_shutdown(signum: int, frame: Any) -> None: - logger.info("Received SIGTERM, delaying 5s for in-flight requests before shutdown...") - time.sleep(5) - sys.exit(0) - - signal.signal(signal.SIGTERM, _handle_shutdown) - - logger.info( - "Starting Gateway Sidecar", - host=args.host, - port=args.port, - debug=args.debug, - threads=args.threads, - health_port=args.health_port, - ) - logger.info("Session authentication required for all container operations") - - # Optional tracemalloc sampler (opt-in via GATEWAY_MEM_TRACE=1). Emits - # periodic RSS + top-allocation-site log records to stdout so the trail - # survives pod OOM via `kubectl logs --previous`. See #1885. - try: - from .mem_trace import start_if_enabled as _mem_trace_start - except ImportError: - from mem_trace import ( # type: ignore[no-redef, import-untyped] - start_if_enabled as _mem_trace_start, - ) - _mem_trace_start() - - # Start dedicated health check server on a separate port so Docker/orchestrator - # health checks are never blocked by long-running git operations on the main - # Waitress thread pool. See: https://github.com/jwbron/egg/issues/1400 - health_thread = threading.Thread( - target=_run_health_server, - args=(args.host, args.health_port), - name="health-check-server", - daemon=True, - ) - health_thread.start() - logger.info("Dedicated health check server started", port=args.health_port) - - # Run with production server in production, debug server in debug mode - if args.debug: - app.run(host=args.host, port=args.port, debug=True) - else: - # Use waitress for production with configurable thread pool. - # Increased from 8 (previous default) to 32 to handle concurrent load - # from multiple SDLC pipelines. See: https://github.com/jwbron/egg/issues/1400 - serve(app, host=args.host, port=args.port, threads=args.threads) - - -if __name__ == "__main__": - main() diff --git a/gateway/gateway/__init__.py b/gateway/gateway/__init__.py new file mode 100644 index 0000000000..c2f7fa124c --- /dev/null +++ b/gateway/gateway/__init__.py @@ -0,0 +1,1343 @@ +""" +Gateway Sidecar - REST API for policy-enforced git/gh operations. + +Provides a REST API that egg containers call to perform git push and gh operations. +The gateway holds GitHub credentials and enforces ownership policies. + +Security: + - Authentication via launcher secret (EGG_LAUNCHER_SECRET) and session tokens + - Listens on all interfaces (containers access via host.docker.internal) + +Endpoints: + POST /api/v1/git/push - Push to remote (policy: branch_ownership or trusted_user) + POST /api/v1/git/fetch - Fetch from remote (no policy - read operations allowed) + POST /api/v1/gh/pr/create - Create PR (policy: blocked in user mode) + POST /api/v1/gh/pr/comment - Comment on PR (policy: none - allowed on any PR) + POST /api/v1/gh/pr/edit - Edit PR (policy: pr_ownership) + POST /api/v1/gh/pr/close - Close PR (policy: pr_ownership) + POST /api/v1/gh/execute - Generic gh command (policy: filtered) + POST /api/v1/jira/ticket/get - Read Jira issue (policy: private-mode, project allowlist) + POST /api/v1/jira/search - JQL search (policy: private-mode, statically project-scoped) + POST /api/v1/jira/ticket/comments - Read Jira issue comments (policy: private-mode, project allowlist) + POST /api/v1/jira/execute - Generic read-only Jira REST call (policy: private-mode, allowlisted path) + POST /api/v1/jira/ticket/create - Create Jira issue (policy: private-mode, project allowlist; #1924) + POST /api/v1/jira/ticket/edit - Edit Jira issue (policy: private-mode, project allowlist; #1924) + POST /api/v1/jira/ticket/comment/add - Add Jira issue comment (policy: private-mode, project allowlist; #1924) + POST /api/v1/jira/issue-link/create - Link two Jira issues (policy: private-mode, both projects allowlisted; #1924) + POST /api/v1/confluence/page/get - Read Confluence page (policy: private-mode, space allowlist) + POST /api/v1/confluence/page/descendants - List page descendants (policy: private-mode, space allowlist) + POST /api/v1/confluence/page/footer-comments - Read page footer comments (policy: private-mode, space allowlist) + POST /api/v1/confluence/page/inline-comments - Read page inline comments (policy: private-mode, space allowlist) + POST /api/v1/confluence/space/list - List allowlisted spaces (policy: private-mode) + POST /api/v1/confluence/space/pages - List pages in a space (policy: private-mode, space allowlist) + POST /api/v1/confluence/search - CQL search (policy: private-mode, statically space-scoped) + POST /api/v1/confluence/execute - Generic read-only Confluence REST call (policy: private-mode, allowlisted path) + GET /api/v1/health - Health check (no auth required) + +Usage: + gateway.py [--host HOST] [--port PORT] [--debug] +""" + +import functools +import os +import secrets +import socket +import subprocess +import sys +import time +import traceback +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import httpx +from flask import Flask, Response, g, jsonify, request +from waitress import serve + +_shared_path = Path(__file__).parent.parent.parent.parent / "shared" + +if _shared_path.exists(): + sys.path.insert(0, str(_shared_path)) + +from egg_health import HealthTracker +from egg_logging import get_logger + +_health_tracker = HealthTracker() + +try: + from ..agent_restrictions import ( + check_agent_gh_operation, + get_agent_pattern, # noqa: F401 — re-exported for test patching + ) + from ..anthropic_credentials import ( + get_credentials_manager, + get_litellm_credentials_manager, + ) + from ..confluence_client import ( + DEFAULT_LIMIT as CONFLUENCE_DEFAULT_LIMIT, + ) + from ..confluence_client import ( + HARD_MAX_LIMIT as CONFLUENCE_HARD_MAX_LIMIT, + ) + from ..confluence_client import ( + ConfluenceCredentialsUnavailable, + ConfluenceResponseTooLarge, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + get_confluence_client, + redact_response, + validate_confluence_api_path, + ) + from ..confluence_credentials import reload_confluence_credentials + from ..confluence_policy import ( + allowed_spaces as confluence_allowed_spaces, + ) + from ..confluence_policy import ( + is_space_allowed as is_confluence_space_allowed, + ) + from ..confluence_policy import ( + reload_confluence_policy, + ) + from ..confluence_search import extract_search_spaces + from ..git_client import ( + GIT_ALLOWED_COMMANDS, + cleanup_credential_helper, + create_credential_helper, + extract_reset_target_ref, + get_authenticated_remote_target, + get_changed_files_in_push, + get_token_for_repo, + git_cmd, + is_branch_switch, + is_branch_switching_operation, + is_repos_parent_directory, + resolve_remote_url, + validate_git_args, + validate_repo_path, + ) + from ..github_client import ( + ALLOWED_GH_COMMANDS, + BLOCKED_GH_COMMANDS, + GH_COMMANDS_BLOCKED_IN_PRIVATE_MODE, + GitHubClient, + extract_comment_edit_info, + extract_issue_label_info, + extract_pr_review_info, + extract_pr_reviewer_info, + extract_repo_from_gh_command, + find_gh_command_index, + get_github_client, + is_gh_command_allowed, + parse_gh_api_args, + resolve_gh_api_template_variables, + validate_gh_api_path, + ) + from ..jira_client import ( + JiraCredentialsUnavailable, + JiraUpstreamError, + get_jira_client, + validate_jira_api_path, + ) + from ..jira_client import ( + validate_fields as validate_jira_fields, + ) + from ..jira_credentials import reload_jira_credentials + from ..jira_policy import ( + epic_link_field as jira_epic_link_field, + ) + from ..jira_policy import ( + extract_project_key, + is_project_allowed, + reload_jira_policy, + ) + from ..jira_policy import ( + link_type_allowed as jira_link_type_allowed, + ) + from ..jira_search import extract_search_projects + from ..mode_gate import require_private_mode + from ..orchestrator_pipelines import ( + fetch_active_pipeline_ids, + wait_for_active_pipeline_ids, + ) + from ..phase_filter import ( + OperationType, + PipelinePhase, + check_agent_restrictions, # noqa: F401 — re-exported for test patching + check_anchor_write_permission, + check_phase_file_restrictions, + filter_operation, + ) + from ..policy import ( + extract_branch_from_refspec, + extract_repo_from_remote, + get_policy_engine, + reload_policy_caches, + ) + from ..private_repo_policy import ( + check_private_repo_access, + ) + from ..rate_limiter import ( + check_heartbeat_rate_limit, + record_failed_lookup, + ) + from ..repo_parser import OWNER_REPO_PATTERN, parse_owner_repo + from ..repo_visibility import get_repo_visibility + from ..routing_policy import ( + RouteHop, + get_routing_policy_manager, + ) + from ..session_manager import ( + get_session_manager, + validate_session_for_request, + ) + from ..upstream_registry import ( + UnknownUpstreamError, + get_upstream_registry, + ) + from ..worktree_manager import ( + REPOS_BASE_DIR, + WORKTREE_BASE_DIR, + WorktreeManager, + get_active_docker_containers, + startup_cleanup, + validate_branch_ref, + validate_identifier, + ) +except ImportError: + from agent_restrictions import ( # type: ignore[no-redef, import-untyped] + check_agent_gh_operation, + get_agent_pattern, # noqa: F401 — re-exported for test patching + ) + from anthropic_credentials import ( # type: ignore[no-redef] + get_credentials_manager, + get_litellm_credentials_manager, + ) + from git_client import ( # type: ignore[no-redef, import-untyped] + GIT_ALLOWED_COMMANDS, + cleanup_credential_helper, + create_credential_helper, + extract_reset_target_ref, + get_authenticated_remote_target, + get_changed_files_in_push, + get_token_for_repo, + git_cmd, + is_branch_switch, + is_branch_switching_operation, + is_repos_parent_directory, + resolve_remote_url, + validate_git_args, + validate_repo_path, + ) + from github_client import ( # type: ignore[no-redef, import-untyped] + ALLOWED_GH_COMMANDS, + BLOCKED_GH_COMMANDS, + GH_COMMANDS_BLOCKED_IN_PRIVATE_MODE, + GitHubClient, + extract_comment_edit_info, + extract_issue_label_info, + extract_pr_review_info, + extract_pr_reviewer_info, + extract_repo_from_gh_command, + find_gh_command_index, + get_github_client, + is_gh_command_allowed, + parse_gh_api_args, + resolve_gh_api_template_variables, + validate_gh_api_path, + ) + + # The Jira modules are new in issue #1556 and the flat-module test + # conftest does not yet preload them. Make the gateway directory + # discoverable before the fallback import so standalone / test loading + # still finds jira_client, jira_credentials, jira_policy, jira_search, + # and mode_gate by name. In production (package import), the relative + # ``from .jira_client import ...`` path above succeeds and this branch + # never runs. + _egg_gateway_dir = str(Path(__file__).parent) + if _egg_gateway_dir not in sys.path: + sys.path.insert(0, _egg_gateway_dir) + from confluence_client import ( # type: ignore[no-redef, import-untyped] + DEFAULT_LIMIT as CONFLUENCE_DEFAULT_LIMIT, + ) + from confluence_client import ( # type: ignore[no-redef] + HARD_MAX_LIMIT as CONFLUENCE_HARD_MAX_LIMIT, + ) + from confluence_client import ( # type: ignore[no-redef] + ConfluenceCredentialsUnavailable, + ConfluenceResponseTooLarge, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + get_confluence_client, + redact_response, + validate_confluence_api_path, + ) + from confluence_credentials import ( # type: ignore[no-redef, import-untyped] + reload_confluence_credentials, + ) + from confluence_policy import ( # type: ignore[no-redef, import-untyped] + allowed_spaces as confluence_allowed_spaces, + ) + from confluence_policy import ( # type: ignore[no-redef] + is_space_allowed as is_confluence_space_allowed, + ) + from confluence_policy import ( # type: ignore[no-redef] + reload_confluence_policy, + ) + from confluence_search import ( # type: ignore[no-redef, import-untyped] + extract_search_spaces, + ) + from jira_client import ( # type: ignore[no-redef, import-untyped] + JiraCredentialsUnavailable, + JiraUpstreamError, + get_jira_client, + validate_jira_api_path, + ) + from jira_client import ( # type: ignore[no-redef] + validate_fields as validate_jira_fields, + ) + from jira_credentials import ( # type: ignore[no-redef, import-untyped] + reload_jira_credentials, + ) + from jira_policy import ( # type: ignore[no-redef, import-untyped] + epic_link_field as jira_epic_link_field, + ) + from jira_policy import ( # type: ignore[no-redef] + extract_project_key, + is_project_allowed, + reload_jira_policy, + ) + from jira_policy import ( # type: ignore[no-redef] + link_type_allowed as jira_link_type_allowed, + ) + from jira_search import ( # type: ignore[no-redef, import-untyped] + extract_search_projects, + ) + from mode_gate import require_private_mode # type: ignore[no-redef, import-untyped] + from orchestrator_pipelines import ( # type: ignore[no-redef, import-untyped] + fetch_active_pipeline_ids, + wait_for_active_pipeline_ids, + ) + from phase_filter import ( # type: ignore[no-redef, import-untyped] + OperationType, + PipelinePhase, + check_agent_restrictions, # noqa: F401 — re-exported for test patching + check_anchor_write_permission, + check_phase_file_restrictions, + filter_operation, + ) + from policy import ( # type: ignore[no-redef, import-untyped] + extract_branch_from_refspec, + extract_repo_from_remote, + get_policy_engine, + reload_policy_caches, + ) + from private_repo_policy import ( # type: ignore[no-redef] + check_private_repo_access, + ) + from rate_limiter import ( # type: ignore[no-redef, import-untyped] + check_heartbeat_rate_limit, + record_failed_lookup, + ) + from repo_parser import ( # type: ignore[no-redef, import-untyped] + OWNER_REPO_PATTERN, + parse_owner_repo, + ) + from repo_visibility import get_repo_visibility # type: ignore[no-redef] + from routing_policy import ( # type: ignore[no-redef, import-untyped] + RouteHop, + get_routing_policy_manager, + ) + from session_manager import ( # type: ignore[no-redef, import-untyped] + get_session_manager, + validate_session_for_request, + ) + from upstream_registry import ( # type: ignore[no-redef, import-untyped] + UnknownUpstreamError, + get_upstream_registry, + ) + from worktree_manager import ( # type: ignore[no-redef, import-untyped] + REPOS_BASE_DIR, + WORKTREE_BASE_DIR, + WorktreeManager, + get_active_docker_containers, + startup_cleanup, + validate_branch_ref, + validate_identifier, + ) + +_config_path = Path(__file__).parent.parent.parent / "config" + +if _config_path.exists() and str(_config_path) not in sys.path: + sys.path.insert(0, str(_config_path)) + +from repo_config import get_auth_mode + +logger = get_logger("gateway") + +# --- decomposition submodules (#3312 slice-3) ------------------------------ +# Each cluster's @app.route handlers are thin wrappers above; their bodies and +# the helper functions/constants live in the _ submodules. The barrel +# re-exports every non-route symbol so gateway.gateway. (external imports +# and unittest.mock.patch targets) keeps resolving after the split. +from . import ( + _confluence, # noqa: E402 + _gh_execute, # noqa: E402 + _gh_ops, # noqa: E402 + _git_execute, # noqa: E402 + _git_ops, # noqa: E402 + _health, # noqa: E402 + _jira, # noqa: E402 + _jira_writes, # noqa: E402 + _proxy, # noqa: E402 + _sessions, # noqa: E402 + _worktree, # noqa: E402 +) +from ._confluence import ( # noqa: E402,F401 + _CONFLUENCE_PAGE_ID_RE, + _CONFLUENCE_SPACE_KEY_RE, + _check_post_fetch_space_allowlist, + _confluence_clamp_limit, + _confluence_error_from_upstream, + _confluence_forbidden_response, + _confluence_not_configured_error, + _confluence_response_too_large, + _confluence_space_denied_response, + _redact_upstream_error_body, + _resolve_space_key_for_payload, + _resolve_space_key_via_list, + _session_confluence_context, + _validate_confluence_page_id, + _validate_confluence_space_key, +) +from ._gh_ops import ( # noqa: E402,F401 + _apply_pr_labels, +) +from ._git_ops import ( # noqa: E402,F401 + _SLICE_INTEGRATION_BRANCH_RE, + LS_REMOTE_VALUE_FLAGS, + _detached_head_hint, +) +from ._health import ( # noqa: E402,F401 + _reload_all_config, +) +from ._helpers import ( # noqa: E402,F401 + _check_orchestrator_connectivity, + _check_squid_health, + _lookup_commit_observer_fn, + audit_log, + make_error, + make_response, + make_success, + make_worktree_not_found_error, +) +from ._jira import ( # noqa: E402,F401 + _JIRA_PROJECT_KEY_RE, + _JIRA_TICKET_KEY_RE, + _TRANSITION_ALLOWLIST, + _is_in_cluster_source, + _jira_error_from_upstream, + _jira_not_configured_error, + _project_not_allowlisted_response, + _session_jira_context, + _verify_orchestrator_transition_auth, +) +from ._jira_writes import ( # noqa: E402,F401 + _JIRA_ALLOWED_ISSUETYPE_NAMES, + _JIRA_BODY_MAX_CHARS, + _JIRA_COMMENT_ALLOWED_KEYS, + _JIRA_CREATE_ALLOWED_KEYS, + _JIRA_EDIT_ALLOWED_KEYS, + _JIRA_LABEL_MAX_CHARS, + _JIRA_LABELS_MAX_COUNT, + _JIRA_LINK_ALLOWED_KEYS, + _JIRA_SUMMARY_MAX_CHARS, + _jira_write_audit_meta, + _validate_jira_labels, + _validate_jira_text_field, + _validate_jira_write_keys, +) +from ._proxy import ( # noqa: E402,F401 + _UPSTREAM_TRANSPORT_ERRORS, + BLOCKED_TOOLS_PRIVATE_MODE, + _attempt_hop_streaming, + _classify_route_status, + _close_quietly, + _extract_wire_model, + _filter_blocked_tools, + _filter_response_headers, + _get_forwarded_headers, + _HopPrepError, + _inject_anthropic_credentials, + _inject_upstream_credentials, + _is_streaming_request, + _prepare_hop, + _PreparedHop, + _resolve_proxy_session, + _resolve_route_chain, + _rewrite_upstream_model, + _sanitize_attribution_value, + _send_and_prime, + _with_attribution_headers, +) +from ._server import ( # noqa: E402,F401 + _run_health_server, + main, +) +from ._sessions import ( # noqa: E402,F401 + VALID_PIPELINE_PHASES, + _branch_exists_on_remote, + _cleanup_container_worktrees, +) +from ._worktree import ( # noqa: E402,F401 + _SLICE_WORKTREE_SUFFIX_RE, + _cleanup_empty_container_dir, + _cleanup_stale_pack_files, + _collect_active_container_ids, + _container_ids_from_sessions, + _derive_worktree_anchor_ids, + _worktree_prune_lock, + map_container_path_to_worktree, +) + +try: + # Production / package mode. + from .._module_loader import load_sibling_gateway_module as _load_sibling_gateway_module +except ImportError: + # Standalone-script mode (the test conftest loads gateway.py as + # a flat top-level module, in which case the relative import + # above raises ImportError before sys.modules has been seeded). + from _module_loader import ( # type: ignore[no-redef, import-untyped] + load_sibling_gateway_module as _load_sibling_gateway_module, + ) + +app = Flask(__name__) + +try: + from ..contract_api import contract_bp + + app.register_blueprint(contract_bp) +except ImportError: + from contract_api import contract_bp # type: ignore[import-untyped, no-redef] + + app.register_blueprint(contract_bp) + +try: + from ..phase_api import phase_bp + + app.register_blueprint(phase_bp) +except ImportError: + from phase_api import phase_bp # type: ignore[import-untyped, no-redef] + + app.register_blueprint(phase_bp) + +try: + from ..artifact_api import artifact_bp + + app.register_blueprint(artifact_bp) +except ImportError: + from artifact_api import artifact_bp # type: ignore[import-untyped, no-redef] + + app.register_blueprint(artifact_bp) + + +@app.errorhandler(Exception) +def handle_unhandled_exception(e: Exception) -> tuple[Response, int]: + """Return JSON for all unhandled exceptions instead of Flask's default HTML.""" + from werkzeug.exceptions import HTTPException + + if isinstance(e, HTTPException): + # Preserve HTTP status codes for werkzeug exceptions (400, 404, etc.) + return jsonify( + { + "success": False, + "message": e.description or str(e), + } + ), e.code or 500 + + logger.error( + "Unhandled exception in request handler", + error=str(e), + error_type=type(e).__name__, + path=request.path if request else "unknown", + traceback=traceback.format_exc(), + ) + return jsonify( + { + "success": False, + "message": "Internal server error", + } + ), 500 + + +DEFAULT_HOST = os.environ.get("GATEWAY_HOST", "0.0.0.0") # Listen on all interfaces by default + +DEFAULT_PORT = 9848 + +DEFAULT_THREADS = int(os.environ.get("GATEWAY_THREADS", "32")) + +HEALTH_CHECK_PORT = int(os.environ.get("GATEWAY_HEALTH_PORT", "9851")) + +HOST_HOME = os.environ.get("HOST_HOME", "") + +CONTAINER_HOME = "/home/egg" + + +def _mountinfo_disabled() -> bool: + return os.environ.get("EGG_DISABLE_MOUNTINFO", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +def _load_mount_mapping() -> list[tuple[str, str]]: + """Read /proc/self/mountinfo and return a list of (mount_point, host_root) tuples. + + For every mount visible to this process, ``mount_point`` is the path + in this process's mount namespace and ``host_root`` is the path the + kernel recorded as the mount root — for kubelet-managed ``hostPath`` + volumes that's the actual host path. The list includes *all* mount + types (not just bind mounts); longest-prefix matching in + ``translate_to_host_path`` ensures the most specific entry wins. + + Note: ``host_root`` (``fields[3]``, the mountinfo *root* field) is + the path relative to the filesystem's root. On single-partition + systems this equals the absolute host path; on multi-partition setups + it may be relative to the partition root. The ``HOST_HOME`` env var + is the escape hatch for those configurations. + + Note: mountinfo uses octal escapes for special characters in paths + (``\\040`` for space, ``\\011`` for tab, ``\\134`` for backslash). + We don't decode them — unlikely to matter for ``/home/...`` paths + but worth knowing if paths ever contain whitespace. + """ + entries: list[tuple[str, str]] = [] + if _mountinfo_disabled(): + return entries + try: + with open("/proc/self/mountinfo") as fh: + for line in fh: + # Format: mount_id parent_id major:minor root mount_point ... + fields = line.split() + if len(fields) < 5: + continue + entries.append((fields[4], fields[3])) + except OSError: + return [] + entries.sort(key=lambda p: len(p[0]), reverse=True) + return entries + + +_MOUNT_MAPPING: list[tuple[str, str]] = _load_mount_mapping() + + +def translate_to_host_path(container_path: str) -> str: + """ + Translate a container path to the corresponding host path. + + Tries in order: + 1. /proc/self/mountinfo — find the longest mount_point that is a + prefix of ``container_path`` and substitute with its host root. + This works for any hostPath volume without configuration. Real + Linux containers always include a rootfs ``/ → /`` entry, so + this strategy is reachable unless explicitly disabled. + 2. ``HOST_HOME`` env var — explicit override. To reach this branch + on Linux, set ``EGG_DISABLE_MOUNTINFO=1`` to skip the mountinfo + lookup (otherwise the ``/`` entry always matches first). + + Args: + container_path: Path inside the gateway container + + Returns: + The corresponding host path, or the original path if no + translation is possible. + """ + for mount_point, host_root in _MOUNT_MAPPING: + if container_path == mount_point or container_path.startswith(mount_point + "/"): + return host_root + container_path[len(mount_point) :] + + if HOST_HOME and container_path.startswith(CONTAINER_HOME): + return container_path.replace(CONTAINER_HOME, HOST_HOME, 1) + + return container_path + + +try: + from ..auth import require_session_auth +except ImportError: + from auth import require_session_auth # type: ignore[no-redef, import-untyped] + +LAUNCHER_SECRET = os.environ.get("EGG_LAUNCHER_SECRET", "") + +LAUNCHER_SECRET_FILE = Path("/secrets/launcher-secret") + + +class LauncherSecretNotConfiguredError(Exception): + """Raised when launcher secret is not configured.""" + + +def get_launcher_secret() -> str: + """Get the launcher secret from environment or file. + + The launcher secret is used to authenticate the egg launcher when + registering sessions. It should be generated by 'egg --setup' and + mounted at /secrets/launcher-secret. + + Raises: + LauncherSecretNotConfiguredError: If launcher secret is not found. + """ + global LAUNCHER_SECRET + + if LAUNCHER_SECRET: + return LAUNCHER_SECRET + + # Try to read from file (mounted from ~/.config/egg/launcher-secret) + if LAUNCHER_SECRET_FILE.exists(): + LAUNCHER_SECRET = LAUNCHER_SECRET_FILE.read_text().strip() + return LAUNCHER_SECRET + + raise LauncherSecretNotConfiguredError( + f"Launcher secret not found at {LAUNCHER_SECRET_FILE} or EGG_LAUNCHER_SECRET env var. " + "Run 'egg --setup' to generate it." + ) + + +def check_launcher_auth() -> tuple[bool, str]: + """ + Check if request has valid launcher authentication. + + Returns: + Tuple of (is_valid, error_message) + """ + secret = get_launcher_secret() + if not secret: + return False, "Launcher secret not configured" + + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return False, "Missing or invalid Authorization header" + + provided_token = auth_header[7:] # Remove "Bearer " prefix + + # Constant-time comparison to prevent timing attacks + if secrets.compare_digest(provided_token, secret): + return True, "" + + return False, "Invalid launcher authorization token" + + +def require_launcher_auth[F: Callable[..., Any]](f: F) -> F: + """Decorator to require launcher authentication for an endpoint.""" + + @functools.wraps(f) + def decorated(*args: Any, **kwargs: Any) -> Any: + is_valid, error = check_launcher_auth() + if not is_valid: + logger.warning( + "Launcher authentication failed", + endpoint=request.path, + error=error, + source_ip=request.remote_addr, + ) + return make_error(error, status_code=401) + return f(*args, **kwargs) + + return decorated # type: ignore[return-value] + + +def require_session_or_launcher_auth[F: Callable[..., Any]](f: F) -> F: + """Endpoint accepts either a session token or the launcher secret. + + When the Authorization bearer matches the launcher secret, the request + is treated as orchestrator-originated: ``g.session`` is left ``None`` + and ``g.auth_actor`` is set to ``"launcher"``. Otherwise the request + falls through to ``require_session_auth`` (sandbox/agent path), which + sets ``g.session`` and ``g.session_mode``/``g.session_phase``; this + wrapper then sets ``g.auth_actor = "session"``. + + The trust split is grounded in the launcher secret already used by + ``/api/v1/sessions/create`` and other privileged endpoints — only the + orchestrator holds it (mounted at ``/secrets/launcher-secret``), so a + request that authenticates with it is by definition not coming from + a sandboxed agent. + + Used by ``/api/v1/git/push`` so the orchestrator can run its own + failsafe pushes (contract init, state-sync, completion) without the + register-session/push/delete ceremony, and without tripping the + pipeline-push block (#2028) intended for agents. + """ + + # Build the session-auth fallback once at decoration time so we don't + # re-create the wrapper closure on every request. + @require_session_auth + def _session_path(*args: Any, **kwargs: Any) -> Any: + g.auth_actor = "session" + return f(*args, **kwargs) + + @functools.wraps(f) + def decorated(*args: Any, **kwargs: Any) -> Any: + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + try: + launcher_secret = get_launcher_secret() + except LauncherSecretNotConfiguredError: + launcher_secret = "" + if launcher_secret and secrets.compare_digest(auth_header[7:], launcher_secret): + g.session = None + g.session_mode = None + g.session_phase = None + g.auth_actor = "launcher" + return f(*args, **kwargs) + + # Fall through to session-token validation. The session decorator + # sets g.session/g.session_mode/g.session_phase on success and + # returns 401 on failure. + return _session_path(*args, **kwargs) + + return decorated # type: ignore[return-value] + + +@app.route("/api/v1/proxy/ca-cert", methods=["GET"]) +def get_proxy_ca_cert() -> tuple[Response, int] | Response: + return _health.get_proxy_ca_cert() + + +@app.route("/api/v1/health", methods=["GET"]) +def health_check() -> Response: + return _health.health_check() + + +@app.route("/api/v1/config/reload", methods=["POST"]) +@require_launcher_auth +def config_reload() -> Response: + return _health.config_reload() + + +@app.route("/api/v1/git/push", methods=["POST"]) +@require_session_or_launcher_auth +def git_push() -> tuple[Response, int] | Response: + return _git_ops.git_push() + + +@app.route("/api/v1/git/execute", methods=["POST"]) +@require_session_auth +def git_execute() -> tuple[Response, int] | Response: + return _git_execute.git_execute() + + +@app.route("/api/v1/git/fetch", methods=["POST"]) +@require_session_auth +def git_fetch() -> tuple[Response, int] | Response: + return _git_ops.git_fetch() + + +@app.route("/api/v1/gh/pr/create", methods=["POST"]) +@require_session_auth +def gh_pr_create() -> tuple[Response, int] | Response: + return _gh_ops.gh_pr_create() + + +@app.route("/api/v1/gh/pr/comment", methods=["POST"]) +@require_session_auth +def gh_pr_comment() -> tuple[Response, int] | Response: + return _gh_ops.gh_pr_comment() + + +@app.route("/api/v1/gh/pr/edit", methods=["POST"]) +@require_session_auth +def gh_pr_edit() -> tuple[Response, int] | Response: + return _gh_ops.gh_pr_edit() + + +@app.route("/api/v1/gh/pr/close", methods=["POST"]) +@require_session_auth +def gh_pr_close() -> tuple[Response, int] | Response: + return _gh_ops.gh_pr_close() + + +@app.route("/api/v1/gh/execute", methods=["POST"]) +@require_session_auth +def gh_execute() -> tuple[Response, int] | Response: + return _gh_execute.gh_execute() + + +@app.route("/api/v1/gh/find_open_pr", methods=["POST"]) +@require_launcher_auth +def gh_find_open_pr() -> tuple[Response, int] | Response: + return _gh_ops.gh_find_open_pr() + + +@app.route("/api/v1/gh/list_open_prs", methods=["POST"]) +@require_launcher_auth +def gh_list_open_prs() -> tuple[Response, int] | Response: + return _gh_ops.gh_list_open_prs() + + +@app.route("/api/v1/gh/pr/merge_state", methods=["POST"]) +@require_launcher_auth +def gh_pr_merge_state() -> tuple[Response, int] | Response: + return _gh_ops.gh_pr_merge_state() + + +@app.route("/api/v1/gh/pr/ready", methods=["POST"]) +@require_launcher_auth +def gh_pr_ready() -> tuple[Response, int] | Response: + return _gh_ops.gh_pr_ready() + + +@app.route("/api/v1/jira/ticket/get", methods=["POST"]) +@require_session_or_launcher_auth +@require_private_mode +def jira_ticket_get() -> tuple[Response, int] | Response: + return _jira.jira_ticket_get() + + +@app.route("/api/v1/jira/search", methods=["POST"]) +@require_session_or_launcher_auth +@require_private_mode +def jira_search() -> tuple[Response, int] | Response: + return _jira.jira_search() + + +@app.route("/api/v1/jira/ticket/comments", methods=["POST"]) +@require_session_auth +@require_private_mode +def jira_ticket_comments() -> tuple[Response, int] | Response: + return _jira.jira_ticket_comments() + + +@app.route("/api/v1/jira/ticket/remotelinks", methods=["POST"]) +@require_session_or_launcher_auth +@require_private_mode +def jira_ticket_remotelinks() -> tuple[Response, int] | Response: + return _jira.jira_ticket_remotelinks() + + +@app.route("/api/v1/jira/ticket/transition", methods=["POST"]) +def jira_ticket_transition() -> tuple[Response, int] | Response: + return _jira.jira_ticket_transition() + + +try: + from ..mode_gate import PRIVATE_MODE_MARKER_ATTR as _PRIVATE_MODE_MARKER_ATTR # noqa: E402 +except ImportError: + from mode_gate import PRIVATE_MODE_MARKER_ATTR as _PRIVATE_MODE_MARKER_ATTR # type: ignore[no-redef] # noqa: E402, I001 + +setattr(jira_ticket_transition, _PRIVATE_MODE_MARKER_ATTR, True) + + +@app.route("/api/v1/jira/execute", methods=["POST"]) +@require_session_auth +@require_private_mode +def jira_execute() -> tuple[Response, int] | Response: + return _jira.jira_execute() + + +@app.route("/api/v1/jira/ticket/create", methods=["POST"]) +@require_session_auth +@require_private_mode +def jira_ticket_create() -> tuple[Response, int] | Response: + return _jira_writes.jira_ticket_create() + + +@app.route("/api/v1/jira/ticket/edit", methods=["POST"]) +@require_session_auth +@require_private_mode +def jira_ticket_edit() -> tuple[Response, int] | Response: + return _jira_writes.jira_ticket_edit() + + +@app.route("/api/v1/jira/ticket/comment/add", methods=["POST"]) +@require_session_auth +@require_private_mode +def jira_ticket_comment_add() -> tuple[Response, int] | Response: + return _jira_writes.jira_ticket_comment_add() + + +@app.route("/api/v1/jira/issue-link/create", methods=["POST"]) +@require_session_auth +@require_private_mode +def jira_issue_link_create() -> tuple[Response, int] | Response: + return _jira_writes.jira_issue_link_create() + + +@app.route("/api/v1/confluence/page/get", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_page_get() -> tuple[Response, int] | Response: + return _confluence.confluence_page_get() + + +@app.route("/api/v1/confluence/page/descendants", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_page_descendants() -> tuple[Response, int] | Response: + return _confluence.confluence_page_descendants() + + +@app.route("/api/v1/confluence/page/footer-comments", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_page_footer_comments() -> tuple[Response, int] | Response: + return _confluence.confluence_page_footer_comments() + + +@app.route("/api/v1/confluence/page/inline-comments", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_page_inline_comments() -> tuple[Response, int] | Response: + return _confluence.confluence_page_inline_comments() + + +@app.route("/api/v1/confluence/space/pages", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_space_pages() -> tuple[Response, int] | Response: + return _confluence.confluence_space_pages() + + +@app.route("/api/v1/confluence/space/list", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_space_list() -> tuple[Response, int] | Response: + return _confluence.confluence_space_list() + + +@app.route("/api/v1/confluence/search", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_search() -> tuple[Response, int] | Response: + return _confluence.confluence_search() + + +@app.route("/api/v1/confluence/execute", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_execute() -> tuple[Response, int] | Response: + return _confluence.confluence_execute() + + +_worktree_manager: WorktreeManager | None = None + + +def get_worktree_manager() -> WorktreeManager: + """Get or create the global WorktreeManager instance.""" + global _worktree_manager + if _worktree_manager is None: + _worktree_manager = WorktreeManager() + return _worktree_manager + + +@app.route("/api/v1/worktree/create", methods=["POST"]) +@require_launcher_auth +def worktree_create() -> tuple[Response, int] | Response: + return _worktree.worktree_create() + + +@app.route("/api/v1/worktree/delete", methods=["POST"]) +@require_launcher_auth +def worktree_delete() -> tuple[Response, int] | Response: + return _worktree.worktree_delete() + + +@app.route("/api/v1/worktree/list", methods=["GET"]) +@require_launcher_auth +def worktree_list() -> tuple[Response, int] | Response: + return _worktree.worktree_list() + + +@app.route("/api/v1/worktrees/prune", methods=["POST"]) +@require_launcher_auth +def worktrees_prune() -> tuple[Response, int] | Response: + return _worktree.worktrees_prune() + + +@app.route("/api/v1/sessions/create", methods=["POST"]) +@require_launcher_auth +def session_create() -> tuple[Response, int] | Response: + return _sessions.session_create() + + +@app.route("/api/v1/sessions/", methods=["DELETE"]) +@require_launcher_auth +def session_delete(session_token: str) -> tuple[Response, int] | Response: + return _sessions.session_delete(session_token) + + +@app.route("/api/v1/sessions/by-container/", methods=["DELETE"]) +@require_launcher_auth +def session_delete_by_container(container_id: str) -> tuple[Response, int] | Response: + return _sessions.session_delete_by_container(container_id) + + +@app.route("/api/v1/sessions/by-container//heartbeat", methods=["POST"]) +@require_launcher_auth +def session_heartbeat_by_container(container_id: str) -> tuple[Response, int] | Response: + return _sessions.session_heartbeat_by_container(container_id) + + +@app.route("/api/v1/sessions/", methods=["GET"]) +@require_launcher_auth +def session_get(session_token: str) -> tuple[Response, int] | Response: + return _sessions.session_get(session_token) + + +@app.route("/api/v1/sessions//heartbeat", methods=["POST"]) +@require_launcher_auth +def session_heartbeat(session_token: str) -> tuple[Response, int] | Response: + return _sessions.session_heartbeat(session_token) + + +@app.route("/api/v1/sessions/", methods=["PATCH"]) +@require_launcher_auth +def session_update(session_token: str) -> tuple[Response, int] | Response: + return _sessions.session_update(session_token) + + +@app.route("/api/v1/sessions//phase", methods=["PATCH"]) +@require_launcher_auth +def session_update_phase(session_token: str) -> tuple[Response, int] | Response: + return _sessions.session_update_phase(session_token) + + +@app.route("/api/v1/repos/visibility", methods=["GET"]) +@require_launcher_auth +def repos_visibility() -> tuple[Response, int] | Response: + return _sessions.repos_visibility() + + +@app.route("/api/v1/sessions", methods=["GET"]) +@require_launcher_auth +def sessions_list() -> tuple[Response, int] | Response: + return _sessions.sessions_list() + + +_anthropic_client: httpx.Client | None = None + + +def get_anthropic_client() -> httpx.Client: + """Get or create the singleton Anthropic API client.""" + global _anthropic_client + if _anthropic_client is None: + _anthropic_client = httpx.Client( + base_url="https://api.anthropic.com", # noqa: EGG200 - gateway proxy client, not direct LLM call + timeout=httpx.Timeout(120.0, connect=10.0), + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + return _anthropic_client + + +ANTHROPIC_BLOCKED_HEADERS = { + "host", + "content-length", + "transfer-encoding", + "authorization", + "x-api-key", + "connection", +} + + +@app.route("/v1/messages", methods=["POST"]) +def proxy_anthropic_messages() -> tuple[Response, int] | Response: + return _proxy.proxy_anthropic_messages() + + +@app.route("/v1/messages/count_tokens", methods=["POST"]) +def proxy_count_tokens() -> tuple[Response, int] | Response: + return _proxy.proxy_count_tokens() + + +if __name__ == "__main__": + main() + +__all__ = [ + "ALLOWED_GH_COMMANDS", + "BLOCKED_GH_COMMANDS", + "BLOCKED_TOOLS_PRIVATE_MODE", + "CONFLUENCE_DEFAULT_LIMIT", + "CONFLUENCE_HARD_MAX_LIMIT", + "ConfluenceCredentialsUnavailable", + "ConfluenceResponseTooLarge", + "ConfluenceUpstreamError", + "ConfluenceUpstreamForbidden", + "GH_COMMANDS_BLOCKED_IN_PRIVATE_MODE", + "GIT_ALLOWED_COMMANDS", + "GitHubClient", + "JiraCredentialsUnavailable", + "JiraUpstreamError", + "LS_REMOTE_VALUE_FLAGS", + "OWNER_REPO_PATTERN", + "OperationType", + "PipelinePhase", + "REPOS_BASE_DIR", + "RouteHop", + "UnknownUpstreamError", + "VALID_PIPELINE_PHASES", + "WORKTREE_BASE_DIR", + "WorktreeManager", + "_CONFLUENCE_PAGE_ID_RE", + "_CONFLUENCE_SPACE_KEY_RE", + "_HopPrepError", + "_JIRA_ALLOWED_ISSUETYPE_NAMES", + "_JIRA_BODY_MAX_CHARS", + "_JIRA_COMMENT_ALLOWED_KEYS", + "_JIRA_CREATE_ALLOWED_KEYS", + "_JIRA_EDIT_ALLOWED_KEYS", + "_JIRA_LABELS_MAX_COUNT", + "_JIRA_LABEL_MAX_CHARS", + "_JIRA_LINK_ALLOWED_KEYS", + "_JIRA_PROJECT_KEY_RE", + "_JIRA_SUMMARY_MAX_CHARS", + "_JIRA_TICKET_KEY_RE", + "_PRIVATE_MODE_MARKER_ATTR", + "_PreparedHop", + "_SLICE_INTEGRATION_BRANCH_RE", + "_SLICE_WORKTREE_SUFFIX_RE", + "_TRANSITION_ALLOWLIST", + "_UPSTREAM_TRANSPORT_ERRORS", + "_apply_pr_labels", + "_attempt_hop_streaming", + "_branch_exists_on_remote", + "_check_orchestrator_connectivity", + "_check_post_fetch_space_allowlist", + "_check_squid_health", + "_classify_route_status", + "_cleanup_container_worktrees", + "_cleanup_empty_container_dir", + "_cleanup_stale_pack_files", + "_close_quietly", + "_collect_active_container_ids", + "_confluence_clamp_limit", + "_confluence_error_from_upstream", + "_confluence_forbidden_response", + "_confluence_not_configured_error", + "_confluence_response_too_large", + "_confluence_space_denied_response", + "_container_ids_from_sessions", + "_derive_worktree_anchor_ids", + "_detached_head_hint", + "_extract_wire_model", + "_filter_blocked_tools", + "_filter_response_headers", + "_get_forwarded_headers", + "_inject_anthropic_credentials", + "_inject_upstream_credentials", + "_is_in_cluster_source", + "_is_streaming_request", + "_jira_error_from_upstream", + "_jira_not_configured_error", + "_jira_write_audit_meta", + "_load_sibling_gateway_module", + "_lookup_commit_observer_fn", + "_prepare_hop", + "_project_not_allowlisted_response", + "_redact_upstream_error_body", + "_reload_all_config", + "_resolve_proxy_session", + "_resolve_route_chain", + "_resolve_space_key_for_payload", + "_resolve_space_key_via_list", + "_rewrite_upstream_model", + "_run_health_server", + "_sanitize_attribution_value", + "_send_and_prime", + "_session_confluence_context", + "_session_jira_context", + "_validate_confluence_page_id", + "_validate_confluence_space_key", + "_validate_jira_labels", + "_validate_jira_text_field", + "_validate_jira_write_keys", + "_verify_orchestrator_transition_auth", + "_with_attribution_headers", + "_worktree_prune_lock", + "app", + "artifact_bp", + "audit_log", + "check_agent_gh_operation", + "check_agent_restrictions", + "check_anchor_write_permission", + "check_heartbeat_rate_limit", + "check_phase_file_restrictions", + "check_private_repo_access", + "cleanup_credential_helper", + "confluence_allowed_spaces", + "contract_bp", + "create_credential_helper", + "extract_branch_from_refspec", + "extract_comment_edit_info", + "extract_issue_label_info", + "extract_pr_review_info", + "extract_pr_reviewer_info", + "extract_project_key", + "extract_repo_from_gh_command", + "extract_repo_from_remote", + "extract_reset_target_ref", + "extract_search_projects", + "extract_search_spaces", + "fetch_active_pipeline_ids", + "filter_operation", + "find_gh_command_index", + "get_active_docker_containers", + "get_agent_pattern", + "get_auth_mode", + "get_authenticated_remote_target", + "get_changed_files_in_push", + "get_confluence_client", + "get_credentials_manager", + "get_github_client", + "get_jira_client", + "get_litellm_credentials_manager", + "get_policy_engine", + "get_repo_visibility", + "get_routing_policy_manager", + "get_session_manager", + "get_token_for_repo", + "get_upstream_registry", + "git_cmd", + "is_branch_switch", + "is_branch_switching_operation", + "is_confluence_space_allowed", + "is_gh_command_allowed", + "is_project_allowed", + "is_repos_parent_directory", + "jira_epic_link_field", + "jira_link_type_allowed", + "main", + "make_error", + "make_response", + "make_success", + "make_worktree_not_found_error", + "map_container_path_to_worktree", + "os", + "parse_gh_api_args", + "parse_owner_repo", + "phase_bp", + "record_failed_lookup", + "redact_response", + "reload_confluence_credentials", + "reload_confluence_policy", + "reload_jira_credentials", + "reload_jira_policy", + "reload_policy_caches", + "require_private_mode", + "require_session_auth", + "resolve_gh_api_template_variables", + "resolve_remote_url", + "serve", + "socket", + "startup_cleanup", + "subprocess", + "time", + "validate_branch_ref", + "validate_confluence_api_path", + "validate_gh_api_path", + "validate_git_args", + "validate_identifier", + "validate_jira_api_path", + "validate_jira_fields", + "validate_repo_path", + "validate_session_for_request", + "wait_for_active_pipeline_ids", +] diff --git a/gateway/gateway/__main__.py b/gateway/gateway/__main__.py new file mode 100644 index 0000000000..1ae5af8f50 --- /dev/null +++ b/gateway/gateway/__main__.py @@ -0,0 +1,15 @@ +"""Container / CLI entry point for the gateway package. + +``gateway.py`` became the ``gateway/gateway/`` sub-package in #3312 slice-18. +The pre-split file was launched as a script (``python3 gateway.py``); a package +is launched with ``python3 -m gateway`` instead (see ``gateway/entrypoint.sh``), +which runs this module. It is a thin shim: the actual server bootstrap lives in +``main`` in the barrel (``__init__.py``), unchanged. +""" + +from __future__ import annotations + +from . import main + +if __name__ == "__main__": + main() diff --git a/gateway/gateway/_confluence.py b/gateway/gateway/_confluence.py new file mode 100644 index 0000000000..516f8bf6fb --- /dev/null +++ b/gateway/gateway/_confluence.py @@ -0,0 +1,1230 @@ +"""Gateway confluence cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import re +from typing import Any + +from flask import Response, g, request + +try: + from ..confluence_client import ( + DEFAULT_LIMIT as CONFLUENCE_DEFAULT_LIMIT, + ) + from ..confluence_client import ( + HARD_MAX_LIMIT as CONFLUENCE_HARD_MAX_LIMIT, + ) + from ..confluence_client import ( + ConfluenceCredentialsUnavailable, + ConfluenceResponseTooLarge, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + redact_response, + validate_confluence_api_path, + ) + from ..confluence_search import ( + extract_search_spaces, + ) +except ImportError: # flat/container import mode + from confluence_client import ( # type: ignore[no-redef, import-untyped] + DEFAULT_LIMIT as CONFLUENCE_DEFAULT_LIMIT, + ) + from confluence_client import ( # type: ignore[no-redef, import-untyped] + HARD_MAX_LIMIT as CONFLUENCE_HARD_MAX_LIMIT, + ) + from confluence_client import ( # type: ignore[no-redef, import-untyped] + ConfluenceCredentialsUnavailable, + ConfluenceResponseTooLarge, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + redact_response, + validate_confluence_api_path, + ) + from confluence_search import ( # type: ignore[no-redef, import-untyped] + extract_search_spaces, + ) + +from ._helpers import make_error, make_success + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + +_CONFLUENCE_PAGE_ID_RE = re.compile(r"^\d+$") + + +_CONFLUENCE_SPACE_KEY_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$") + + +def _session_confluence_context() -> dict[str, Any]: + """Return session-scoped fields to include in Confluence audit records. + + Per refine decision 13 there is no per-session ``session.confluence_*`` + field — pageId / spaceKey are recovered from the request body or + response per call. + """ + ctx: dict[str, Any] = { + "session_mode": getattr(g, "session_mode", None), + } + session = getattr(g, "session", None) + if session is not None: + ctx["pipeline_id"] = getattr(session, "pipeline_id", None) + ctx["agent_role"] = getattr(session, "agent_role", None) + return ctx + + +def _confluence_error_from_upstream(exc: ConfluenceUpstreamError) -> tuple[Response, int]: + """Translate a ``ConfluenceUpstreamError`` to an HTTP response. + + Atlassian error envelopes occasionally include user-identifying strings + (e.g. account ids embedded in messages) and space-enumeration leaks + (e.g. ``"valid keys are: ENG, DOCS, SECRET"``). The success-path + redactor only runs on 2xx bodies, so we apply it here too before the + upstream body crosses the gateway/sandbox boundary. + """ + if 300 <= exc.status_code < 400: + # A 3xx is never a valid read response from the Atlassian REST API — + # it's the signature of an unauthenticated/misrouted request being + # bounced to the login page. The usual cause is a missing/invalid + # gateway Atlassian token or a wrong base URL (e.g. ATLASSIAN_BASE_URL + # set to a page browser URL, or CONFLUENCE_BASE_URL missing the + # ``/wiki`` suffix). Surface that pointedly instead of an opaque 502 so + # operators don't have to reverse-engineer the redirect. Still 502 + # (bad upstream response), distinct from the 503 "creds absent" path. + message = ( + f"Confluence upstream returned {exc.status_code} (redirect) — the " + "gateway received a login redirect instead of a REST response. " + "This usually means the gateway's Atlassian credentials are " + "missing/invalid or the base URL is wrong (e.g. ATLASSIAN_BASE_URL " + "must be the bare tenant origin, or CONFLUENCE_BASE_URL must include " + "the /wiki suffix)." + ) + details: dict[str, Any] = { + "upstream_status": exc.status_code, + "upstream_body": _redact_upstream_error_body(exc.body), + "path": exc.path, + "likely_cause": "missing_or_invalid_atlassian_credentials_or_base_url", + } + # Surface the upstream ``Location`` when present so the operator can + # confirm the bounce target (typically ``/login`` or the tenant root) + # without reproducing. + if exc.location: + message += f" Upstream redirected to: {exc.location}" + details["upstream_location"] = exc.location + return make_error(message, status_code=502, details=details) + if 400 <= exc.status_code < 500: + status = exc.status_code + else: + status = 502 + return make_error( + f"Confluence upstream error {exc.status_code}", + status_code=status, + details={ + "upstream_status": exc.status_code, + "upstream_body": _redact_upstream_error_body(exc.body), + "path": exc.path, + }, + ) + + +def _redact_upstream_error_body(body: Any) -> Any: + """Run ``redact_response`` over an Atlassian error envelope. + + Atlassian returns errors as JSON dicts (and very occasionally as plain + text); the redactor mutates dicts/lists in place. Non-container shapes + pass through unchanged. + """ + if isinstance(body, (dict, list)): + return redact_response(body) + return body + + +def _confluence_not_configured_error( + exc: ConfluenceCredentialsUnavailable, +) -> tuple[Response, int]: + """Translate missing credentials to an HTTP 503 response.""" + return make_error( + "Confluence credentials not configured on the gateway", + status_code=503, + details={"reason": str(exc)}, + ) + + +def _confluence_response_too_large( + exc: ConfluenceResponseTooLarge, + *, + page_id: str | None = None, + space_key: str | None = None, +) -> tuple[Response, int]: + """Translate an oversized response to HTTP 413.""" + details: dict[str, Any] = {"size_bytes": exc.size_bytes, "path": exc.path} + if page_id is not None: + details["pageId"] = page_id + if space_key is not None: + details["spaceKey"] = space_key + return make_error( + "Confluence response too large", + status_code=413, + details=details, + ) + + +def _confluence_forbidden_response( + exc: ConfluenceUpstreamForbidden, + *, + event: str, + page_id: str | None = None, + space_key: str | None = None, +) -> tuple[Response, int]: + """Translate an upstream 403 into HTTP 403 with the dedicated audit event.""" + details: dict[str, Any] = { + "upstream_status": 403, + "reason": "bot_account_lacks_read_access", + "path": exc.path, + **_session_confluence_context(), + } + if page_id is not None: + details["pageId"] = page_id + if space_key is not None: + details["spaceKey"] = space_key + _b().audit_log(event, event, success=False, details=details) + body: dict[str, Any] = { + "status": "forbidden", + "reason": "bot_account_lacks_read_access", + } + if page_id is not None: + body["pageId"] = page_id + if space_key is not None: + body["spaceKey"] = space_key + return make_error( + "Confluence upstream forbidden", + status_code=403, + details=body, + ) + + +def _confluence_space_denied_response( + *, + event: str, + page_id: str | None, + space_key: str | None, + reason: str, + extra: dict[str, Any] | None = None, +) -> tuple[Response, int]: + """Emit a structured audit record and return the canonical 403.""" + details: dict[str, Any] = {"spaceKey": space_key, "reason": reason} + if page_id is not None: + details["pageId"] = page_id + if extra: + details.update(extra) + details.update(_session_confluence_context()) + _b().audit_log(event, event, success=False, details=details) + return make_error( + "Confluence space not allowlisted", + status_code=403, + details={"spaceKey": space_key, "reason": reason}, + ) + + +def _resolve_space_key_for_payload(payload: Any) -> str | None: + """Extract a ``spaceKey`` from an upstream payload, using the client's + space cache if only ``spaceId`` is present. + + Returns the space key on success; ``None`` if the payload doesn't carry + one (e.g. v1 fallback with no spaceId — caller falls back to a manual + list_spaces lookup). + """ + if not isinstance(payload, dict): + return None + direct = payload.get("spaceKey") or payload.get("space_key") + if isinstance(direct, str) and direct: + return direct + # v2 returns ``spaceId`` on page reads; the client caches the mapping + # opportunistically once ``list_spaces`` runs. + space_id = payload.get("spaceId") + if space_id is None: + space = payload.get("space") + if isinstance(space, dict): + sk = space.get("key") + if isinstance(sk, str) and sk: + return sk + space_id = space.get("id") + if space_id is None: + return None + client = _b().get_confluence_client() + return client.space_cache.key_for_id(str(space_id)) + + +def _resolve_space_key_via_list(allowed: frozenset[str], space_id: str | None) -> str | None: + """Look up a space key for a space id by warming the space cache. + + Used by the post-fetch allowlist check when the page response carries + ``spaceId`` but the cache hasn't been populated yet. Returns ``None`` + if the space isn't visible to the bot (which is itself a deny signal). + + ``allowed`` is unused at this layer; the cache is populated with every + space the bot can see and the post-fetch allowlist check applies the + operator allowlist on the resolved key. + """ + del allowed # cache holds every visible space; allowlist enforced upstream + if not space_id: + return None + client = _b().get_confluence_client() + cached = client.space_cache.key_for_id(str(space_id)) + if cached is not None: + return cached + # Walk paginated /wiki/api/v2/spaces so a target space on page 2+ still + # resolves. populate_space_cache caps iterations defensively. + try: + client.populate_space_cache() + except ( + ConfluenceCredentialsUnavailable, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + ): + # Forbidden on /wiki/api/v2/spaces (bot lacks space:read globally) + # is not its own ConfluenceUpstreamError subclass — catch it here + # so the outer post-fetch check fail-closes through + # confluence_space_denied rather than leaking a Flask 500. + return None + return client.space_cache.key_for_id(str(space_id)) + + +def _confluence_clamp_limit(value: Any) -> int | None: + """Coerce + clamp a caller-supplied limit (1..HARD_MAX_LIMIT).""" + if value is None: + return None + try: + parsed = int(value) + except TypeError, ValueError: + raise ValueError("limit must be an integer") from None + if parsed <= 0: + raise ValueError("limit must be positive") + return min(parsed, CONFLUENCE_HARD_MAX_LIMIT) + + +def _validate_confluence_page_id(page_id: Any) -> tuple[bool, str]: + if not isinstance(page_id, str) or not _CONFLUENCE_PAGE_ID_RE.fullmatch(page_id): + return False, "invalid pageId shape" + return True, "" + + +def _validate_confluence_space_key(space_key: Any) -> tuple[bool, str]: + if not isinstance(space_key, str) or not _CONFLUENCE_SPACE_KEY_RE.fullmatch(space_key): + return False, "invalid spaceKey shape" + return True, "" + + +def _check_post_fetch_space_allowlist( + payload: Any, + *, + allowed: frozenset[str], + page_id: str | None, +) -> tuple[bool, str | None]: + """Verify the response's spaceKey is in the allowlist. + + Returns ``(ok, space_key)``. When ``ok`` is False the route returns + HTTP 403 without forwarding the response body; ``space_key`` is the + resolved key for audit purposes (may be ``None`` if unresolvable). + """ + if not isinstance(payload, dict): + return False, None + if payload.get("status") == "not_found": + # 404 envelope passes through — no space leakage. + return True, None + space_key = _resolve_space_key_for_payload(payload) + if space_key is None: + space_id = payload.get("spaceId") + if isinstance(space_id, (str, int)): + space_key = _resolve_space_key_via_list(allowed, str(space_id)) + if space_key is None: + # Couldn't resolve — fail closed. This protects against the upstream + # response shape changing. + return False, None + return space_key in allowed, space_key + + +def confluence_page_get() -> tuple[Response, int] | Response: + """Fetch a single Confluence page (v2). + + Request body:: + + {"pageId": "12345", + "bodyFormat": ["storage"], + "expand": null} + """ + data = request.get_json(silent=True) or {} + page_id = data.get("pageId") + body_format = data.get("bodyFormat") + expand = data.get("expand") + + ok, reason = _validate_confluence_page_id(page_id) + if not ok: + _b().audit_log( + "confluence_page_get_rejected", + "confluence_page_get", + success=False, + details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, + ) + return make_error( + "Invalid pageId (expected numeric string)", + status_code=400, + details={"pageId": page_id}, + ) + assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id + + allowed = _b().confluence_allowed_spaces() + try: + body = ( + _b().get_confluence_client().get_page(page_id, body_format=body_format, expand=expand) + ) + except ValueError as exc: + _b().audit_log( + "confluence_page_get_rejected", + "confluence_page_get", + success=False, + details={"reason": str(exc), "pageId": page_id, **_session_confluence_context()}, + ) + return make_error(f"Invalid request: {exc}", status_code=400) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) + except ConfluenceResponseTooLarge as exc: + _b().audit_log( + "confluence_response_too_large", + "confluence_page_get", + success=False, + details={ + "pageId": page_id, + "size_bytes": exc.size_bytes, + **_session_confluence_context(), + }, + ) + return _confluence_response_too_large(exc, page_id=page_id) + except ConfluenceUpstreamError as exc: + _b().audit_log( + "confluence_page_get_upstream_error", + "confluence_page_get", + success=False, + details={ + "pageId": page_id, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + ok_space, space_key = _check_post_fetch_space_allowlist(body, allowed=allowed, page_id=page_id) + if not ok_space: + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=space_key, + reason="space not allowlisted", + ) + + _b().audit_log( + "confluence_page_get", + "confluence_page_get", + success=True, + details={ + "pageId": page_id, + "spaceKey": space_key, + "not_found": body.get("status") == "not_found", + **_session_confluence_context(), + }, + ) + return make_success("Confluence page fetched", body) + + +def confluence_page_descendants() -> tuple[Response, int] | Response: + """List the descendants of a Confluence page.""" + data = request.get_json(silent=True) or {} + page_id = data.get("pageId") + depth = data.get("depth") + limit_raw = data.get("limit") + cursor = data.get("cursor") + + ok, reason = _validate_confluence_page_id(page_id) + if not ok: + _b().audit_log( + "confluence_page_descendants_rejected", + "confluence_page_descendants", + success=False, + details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, + ) + return make_error( + "Invalid pageId (expected numeric string)", + status_code=400, + details={"pageId": page_id}, + ) + assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id + + # Apply sensible defaults for runaway-tree protection (risk R8). + if depth is None: + depth = 1 + if limit_raw is None: + limit_raw = CONFLUENCE_DEFAULT_LIMIT + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + _b().audit_log( + "confluence_page_descendants_rejected", + "confluence_page_descendants", + success=False, + details={"reason": str(exc), "pageId": page_id, **_session_confluence_context()}, + ) + return make_error(f"Invalid limit: {exc}", status_code=400) + + allowed = _b().confluence_allowed_spaces() + try: + body = ( + _b() + .get_confluence_client() + .get_page_descendants( + page_id, + depth=depth, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + ) + ) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc, page_id=page_id) + except ConfluenceUpstreamError as exc: + _b().audit_log( + "confluence_page_descendants_upstream_error", + "confluence_page_descendants", + success=False, + details={ + "pageId": page_id, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + # Resolve the parent page's space for the allowlist check. The + # descendants response doesn't carry it directly, so we fetch the parent + # page once (cheap — the v2 page endpoint is small). + parent_space_key: str | None = None + if body.get("status") != "not_found": + try: + parent = _b().get_confluence_client().get_page(page_id, body_format=("storage",)) + except ( + ConfluenceCredentialsUnavailable, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + ): + parent = None + if parent is not None and parent.get("status") != "not_found": + ok_space, parent_space_key = _check_post_fetch_space_allowlist( + parent, allowed=allowed, page_id=page_id + ) + if not ok_space: + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=parent_space_key, + reason="space not allowlisted", + ) + else: + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=None, + reason="parent page space could not be resolved", + ) + + _b().audit_log( + "confluence_page_descendants", + "confluence_page_descendants", + success=True, + details={ + "pageId": page_id, + "spaceKey": parent_space_key, + "depth": depth, + "limit": limit, + **_session_confluence_context(), + }, + ) + return make_success("Confluence descendants fetched", body) + + +def confluence_page_footer_comments() -> tuple[Response, int] | Response: + """Fetch footer comments on a Confluence page.""" + data = request.get_json(silent=True) or {} + page_id = data.get("pageId") + body_format = data.get("bodyFormat") + include_replies = bool(data.get("includeReplies")) + limit_raw = data.get("limit") + cursor = data.get("cursor") + + ok, reason = _validate_confluence_page_id(page_id) + if not ok: + _b().audit_log( + "confluence_page_footer_comments_rejected", + "confluence_page_footer_comments", + success=False, + details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, + ) + return make_error( + "Invalid pageId (expected numeric string)", + status_code=400, + details={"pageId": page_id}, + ) + assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id + + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + return make_error(f"Invalid limit: {exc}", status_code=400) + + allowed = _b().confluence_allowed_spaces() + try: + body = ( + _b() + .get_confluence_client() + .get_page_footer_comments( + page_id, + body_format=body_format, + include_replies=include_replies, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + ) + ) + except ValueError as exc: + return make_error(f"Invalid request: {exc}", status_code=400) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc, page_id=page_id) + except ConfluenceUpstreamError as exc: + _b().audit_log( + "confluence_page_footer_comments_upstream_error", + "confluence_page_footer_comments", + success=False, + details={ + "pageId": page_id, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + parent_space_key: str | None = None + if body.get("status") != "not_found": + try: + parent = _b().get_confluence_client().get_page(page_id, body_format=("storage",)) + except ( + ConfluenceCredentialsUnavailable, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + ): + parent = None + if parent is not None and parent.get("status") != "not_found": + ok_space, parent_space_key = _check_post_fetch_space_allowlist( + parent, allowed=allowed, page_id=page_id + ) + if not ok_space: + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=parent_space_key, + reason="space not allowlisted", + ) + else: + # Fail-closed when the parent page's space cannot be resolved + # (parent fetch raised, or returned the not_found envelope while + # the comment fetch returned data — Atlassian's per-page + # restriction inheritance can produce exactly this shape). + # We MUST NOT ship the comment body to the sandbox without an + # allowlist verdict. + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=None, + reason="parent page space could not be resolved", + ) + + _b().audit_log( + "confluence_page_footer_comments", + "confluence_page_footer_comments", + success=True, + details={ + "pageId": page_id, + "spaceKey": parent_space_key, + "includeReplies": include_replies, + **_session_confluence_context(), + }, + ) + return make_success("Confluence footer comments fetched", body) + + +def confluence_page_inline_comments() -> tuple[Response, int] | Response: + """Fetch inline comments on a Confluence page (with v1 fallback).""" + data = request.get_json(silent=True) or {} + page_id = data.get("pageId") + body_format = data.get("bodyFormat") + limit_raw = data.get("limit") + cursor = data.get("cursor") + + ok, reason = _validate_confluence_page_id(page_id) + if not ok: + _b().audit_log( + "confluence_page_inline_comments_rejected", + "confluence_page_inline_comments", + success=False, + details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, + ) + return make_error( + "Invalid pageId (expected numeric string)", + status_code=400, + details={"pageId": page_id}, + ) + assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id + + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + return make_error(f"Invalid limit: {exc}", status_code=400) + + allowed = _b().confluence_allowed_spaces() + try: + body = ( + _b() + .get_confluence_client() + .get_page_inline_comments( + page_id, + body_format=body_format, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + ) + ) + except ValueError as exc: + return make_error(f"Invalid request: {exc}", status_code=400) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc, page_id=page_id) + except ConfluenceUpstreamError as exc: + _b().audit_log( + "confluence_page_inline_comments_upstream_error", + "confluence_page_inline_comments", + success=False, + details={ + "pageId": page_id, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + used_fallback = bool(body.get("used_fallback")) + parent_space_key: str | None = None + if body.get("status") != "not_found": + try: + parent = _b().get_confluence_client().get_page(page_id, body_format=("storage",)) + except ( + ConfluenceCredentialsUnavailable, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + ): + parent = None + if parent is not None and parent.get("status") != "not_found": + ok_space, parent_space_key = _check_post_fetch_space_allowlist( + parent, allowed=allowed, page_id=page_id + ) + if not ok_space: + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=parent_space_key, + reason="space not allowlisted", + ) + else: + # Fail-closed when the parent page's space cannot be resolved. + # See confluence_page_footer_comments — same risk applies here: + # the v1 fallback can return inline comments even when v2 page + # reads 403, so we MUST NOT ship the body without an allowlist + # verdict. + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=None, + reason="parent page space could not be resolved", + ) + + _b().audit_log( + "confluence_page_inline_comments", + "confluence_page_inline_comments", + success=True, + details={ + "pageId": page_id, + "spaceKey": parent_space_key, + "used_fallback": used_fallback, + **_session_confluence_context(), + }, + ) + return make_success("Confluence inline comments fetched", body) + + +def confluence_space_pages() -> tuple[Response, int] | Response: + """List pages in a Confluence space.""" + data = request.get_json(silent=True) or {} + space_key = data.get("spaceKey") + limit_raw = data.get("limit") + cursor = data.get("cursor") + body_format = data.get("bodyFormat") + + ok, reason = _validate_confluence_space_key(space_key) + if not ok: + _b().audit_log( + "confluence_space_pages_rejected", + "confluence_space_pages", + success=False, + details={"reason": reason, "spaceKey": space_key, **_session_confluence_context()}, + ) + return make_error( + "Invalid spaceKey", + status_code=400, + details={"spaceKey": space_key}, + ) + assert isinstance(space_key, str) # narrowed by _validate_confluence_space_key + + if not _b().is_confluence_space_allowed(space_key): + return _confluence_space_denied_response( + event="confluence_space_pages_denied", + page_id=None, + space_key=space_key, + reason="space not allowlisted", + ) + + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + return make_error(f"Invalid limit: {exc}", status_code=400) + + client = _b().get_confluence_client() + + # Resolve spaceKey → spaceId, using the cache when populated. Walk + # paginated /wiki/api/v2/spaces so tenants with more spaces than fit on + # one v2 page still resolve a target on page 2+. + space_id = client.space_cache.id_for_key(space_key) + if space_id is None: + try: + client.populate_space_cache() + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response( + exc, event="confluence_upstream_403", space_key=space_key + ) + except ConfluenceUpstreamError as exc: + return _confluence_error_from_upstream(exc) + space_id = client.space_cache.id_for_key(space_key) + + if space_id is None: + return make_error( + "Confluence space not found or not visible to bot account", + status_code=404, + details={"status": "not_found", "spaceKey": space_key}, + ) + + try: + body = client.get_space_pages( + space_id, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + body_format=body_format, + ) + except ValueError as exc: + return make_error(f"Invalid request: {exc}", status_code=400) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response( + exc, event="confluence_upstream_403", space_key=space_key + ) + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc, space_key=space_key) + except ConfluenceUpstreamError as exc: + _b().audit_log( + "confluence_space_pages_upstream_error", + "confluence_space_pages", + success=False, + details={ + "spaceKey": space_key, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + _b().audit_log( + "confluence_space_pages", + "confluence_space_pages", + success=True, + details={ + "spaceKey": space_key, + "limit": limit, + **_session_confluence_context(), + }, + ) + return make_success("Confluence space pages fetched", body) + + +def confluence_space_list() -> tuple[Response, int] | Response: + """List Confluence spaces (filtered to the operator's allowlist).""" + data = request.get_json(silent=True) or {} + limit_raw = data.get("limit") + cursor = data.get("cursor") + + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + return make_error(f"Invalid limit: {exc}", status_code=400) + + allowed = _b().confluence_allowed_spaces() + + try: + body = ( + _b() + .get_confluence_client() + .list_spaces( + allowed_spaces=allowed, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + ) + ) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response(exc, event="confluence_upstream_403") + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc) + except ConfluenceUpstreamError as exc: + _b().audit_log( + "confluence_space_list_upstream_error", + "confluence_space_list", + success=False, + details={ + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + spaces_returned = 0 + if isinstance(body, dict): + results = body.get("results") + if isinstance(results, list): + spaces_returned = len(results) + + _b().audit_log( + "confluence_space_list", + "confluence_space_list", + success=True, + details={ + "spaces_returned": spaces_returned, + **_session_confluence_context(), + }, + ) + return make_success("Confluence spaces fetched", body) + + +def confluence_search() -> tuple[Response, int] | Response: + """Run a CQL search against Atlassian Cloud Confluence. + + Request body:: + + {"cql": "space = ENG AND text ~ \"rfc\"", + "limit": 50, + "cursor": null} + + The CQL must be statically provable as scoped to allowlisted spaces. + """ + data = request.get_json(silent=True) or {} + cql = data.get("cql") + limit_raw = data.get("limit") + cursor = data.get("cursor") + + if not isinstance(cql, str) or not cql.strip(): + _b().audit_log( + "confluence_search_rejected", + "confluence_search", + success=False, + details={"reason": "cql required", **_session_confluence_context()}, + ) + return make_error("cql is required", status_code=400) + + allowed = _b().confluence_allowed_spaces() + scope = extract_search_spaces(cql, allowed) + if scope.spaces is None: + _b().audit_log( + "confluence_search_rejected", + "confluence_search", + success=False, + details={ + "reason": scope.reason, + "cql_length": len(cql), + **_session_confluence_context(), + }, + ) + return make_error( + f"CQL rejected: {scope.reason}", + status_code=403, + details={"reason": scope.reason}, + ) + + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + return make_error(f"Invalid limit: {exc}", status_code=400) + + try: + body = ( + _b() + .get_confluence_client() + .search_cql( + cql=cql, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + ) + ) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response(exc, event="confluence_upstream_403") + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc) + except ConfluenceUpstreamError as exc: + _b().audit_log( + "confluence_search_upstream_error", + "confluence_search", + success=False, + details={ + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + _b().audit_log( + "confluence_search", + "confluence_search", + success=True, + details={ + "spaces_extracted": sorted(scope.spaces), + "cql_length": len(cql), + "limit": limit, + "cursor_present": bool(cursor), + **_session_confluence_context(), + }, + ) + return make_success("Confluence search executed", body) + + +def confluence_execute() -> tuple[Response, int] | Response: + """Generic read-only passthrough for whitelisted Confluence REST paths. + + Request body:: + + {"method": "GET", + "path": "api/v2/pages/12345", + "query": {"body-format": "storage"}, + "body": null} + """ + data = request.get_json(silent=True) or {} + method = data.get("method") or "GET" + path = data.get("path") + query = data.get("query") + req_body = data.get("body") + + if not isinstance(path, str) or not path: + _b().audit_log( + "confluence_execute_rejected", + "confluence_execute", + success=False, + details={"reason": "path required", **_session_confluence_context()}, + ) + return make_error("path is required", status_code=400) + + if not isinstance(method, str): + _b().audit_log( + "confluence_execute_rejected", + "confluence_execute", + success=False, + details={"reason": "method must be a string", **_session_confluence_context()}, + ) + return make_error("method must be a string", status_code=400) + + method_upper = method.upper() + ok, reason = validate_confluence_api_path(path, method_upper) + if not ok: + _b().audit_log( + "confluence_execute_denied", + "confluence_execute", + success=False, + details={ + "method": method_upper, + "path": path, + "reason": reason, + **_session_confluence_context(), + }, + ) + return make_error( + f"Confluence API call rejected: {reason}", + status_code=403, + details={"method": method_upper, "path": path, "reason": reason}, + ) + + stripped = path.strip("/").split("?", 1)[0] + head = stripped.split("/") + page_id: str | None = None + space_id_in_path: str | None = None + if len(head) >= 4 and head[0] == "api" and head[1] == "v2" and head[2] == "pages": + # api/v2/pages/ + if head[3].isdigit(): + page_id = head[3] + elif len(head) >= 5 and head[0] == "api" and head[1] == "v2" and head[2] == "spaces": + # api/v2/spaces//pages + if head[3].isdigit(): + space_id_in_path = head[3] + + # Anti-bypass invariant (issue #1931 cycle-3 NACK from reviewer_code + + # reviewer_security): the four path families an attacker could use to + # bypass narrow-route safeguards — ``rest/api/search`` (CQL extractor + # bypass), ``api/v2/spaces`` (allowlist-filter bypass), + # ``api/v2/footer-comments`` / ``api/v2/inline-comments`` (flat + # endpoints with page-id-in-query and no upstream spaceKey filter) — + # are dropped from CONFLUENCE_API_ALLOWED_PATHS in confluence_client.py, + # so reaching this point implies a page- or space-scoped path family. + # All of those carry an id inline that the post-fetch allowlist check + # below resolves to a spaceKey. + + if query is not None and not isinstance(query, dict): + return make_error("query must be an object", status_code=400) + if req_body is not None and not isinstance(req_body, dict): + return make_error("body must be an object", status_code=400) + + allowed = _b().confluence_allowed_spaces() + client = _b().get_confluence_client() + + try: + body = client.execute_raw( + method=method_upper, + path=stripped, + query=query, + body=req_body, + ) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc, page_id=page_id) + except ConfluenceUpstreamError as exc: + _b().audit_log( + "confluence_execute_upstream_error", + "confluence_execute", + success=False, + details={ + "method": method_upper, + "path": stripped, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + # Post-fetch allowlist check for path families that carry an id inline. + audited_space_key: str | None = None + if page_id is not None and isinstance(body, dict) and body.get("status") != "not_found": + ok_space, audited_space_key = _check_post_fetch_space_allowlist( + body, allowed=allowed, page_id=page_id + ) + if not ok_space: + return _confluence_space_denied_response( + event="confluence_execute_denied", + page_id=page_id, + space_key=audited_space_key, + reason="space not allowlisted", + extra={"method": method_upper, "path": stripped}, + ) + elif space_id_in_path is not None: + resolved = client.space_cache.key_for_id(space_id_in_path) + if resolved is None: + # Walk paginated /wiki/api/v2/spaces so a target on page 2+ + # still resolves. Catch ConfluenceUpstreamForbidden alongside + # the other upstream errors — it's a sibling of + # ConfluenceUpstreamError (both inherit from RuntimeError, not + # one from the other) and would otherwise escape as a Flask + # 500 when the bot lacks space:read globally. Mirrors the + # handler at _resolve_space_key_via_list. + try: + client.populate_space_cache() + except ( + ConfluenceCredentialsUnavailable, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + ): + resolved = None + else: + resolved = client.space_cache.key_for_id(space_id_in_path) + if resolved is None or resolved not in allowed: + return _confluence_space_denied_response( + event="confluence_execute_denied", + page_id=None, + space_key=resolved, + reason="space not allowlisted", + extra={"method": method_upper, "path": stripped}, + ) + audited_space_key = resolved + + _b().audit_log( + "confluence_execute", + "confluence_execute", + success=True, + details={ + "method": method_upper, + "path": stripped, + "pageId": page_id, + "spaceKey": audited_space_key, + **_session_confluence_context(), + }, + ) + return make_success("Confluence API call executed", body) diff --git a/gateway/gateway/_gh_execute.py b/gateway/gateway/_gh_execute.py new file mode 100644 index 0000000000..71c2f0d40d --- /dev/null +++ b/gateway/gateway/_gh_execute.py @@ -0,0 +1,763 @@ +"""Gateway gh_execute cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import os +import re +from typing import Any + +from flask import Response, g, request + +try: + from ..agent_restrictions import ( + check_agent_gh_operation, + ) + from ..github_client import ( + ALLOWED_GH_COMMANDS, + BLOCKED_GH_COMMANDS, + GH_COMMANDS_BLOCKED_IN_PRIVATE_MODE, + extract_comment_edit_info, + extract_issue_label_info, + extract_pr_review_info, + extract_pr_reviewer_info, + extract_repo_from_gh_command, + find_gh_command_index, + is_gh_command_allowed, + parse_gh_api_args, + validate_gh_api_path, + ) + from ..phase_filter import ( + OperationType, + filter_operation, + ) + from ..repo_parser import ( + parse_owner_repo, + ) +except ImportError: # flat/container import mode + from agent_restrictions import ( # type: ignore[no-redef, import-untyped] + check_agent_gh_operation, + ) + from github_client import ( # type: ignore[no-redef, import-untyped] + ALLOWED_GH_COMMANDS, + BLOCKED_GH_COMMANDS, + GH_COMMANDS_BLOCKED_IN_PRIVATE_MODE, + extract_comment_edit_info, + extract_issue_label_info, + extract_pr_review_info, + extract_pr_reviewer_info, + extract_repo_from_gh_command, + find_gh_command_index, + is_gh_command_allowed, + parse_gh_api_args, + validate_gh_api_path, + ) + from phase_filter import ( # type: ignore[no-redef, import-untyped] + OperationType, + filter_operation, + ) + from repo_parser import ( # type: ignore[no-redef, import-untyped] + parse_owner_repo, + ) + +from ._helpers import make_error, make_success + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + + +def gh_execute() -> tuple[Response, int] | Response: + """ + Execute a generic gh command. + + Request body: + { + "args": ["pr", "view", "123"], + "cwd": "/path/to/repo" # optional + } + + Policy: Filtered - only read-only operations allowed by default. + Blocked commands return 403. + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + args = data.get("args", []) + cwd = data.get("cwd") + # Repo passed from container - container can detect repo from worktree, + # but gateway can't (different git structure) + payload_repo = data.get("repo") + + if not args: + return make_error("Missing args") + + # Get session mode from request context (set by @require_session_auth decorator) + session_mode = getattr(g, "session_mode", None) + + # Check for commands blocked entirely in private mode (too broad to filter by repo) + if session_mode == "private" and args and args[0] in GH_COMMANDS_BLOCKED_IN_PRIVATE_MODE: + _b().audit_log( + "gh_command_blocked_private_mode", + "gh_execute", + success=False, + details={ + "command": args[0], + "reason": "Command blocked in private mode (too broad)", + }, + ) + return make_error( + f"Command 'gh {args[0]}' is not allowed in private mode", + status_code=403, + details={"command": args[0], "session_mode": "private"}, + ) + + # Check for blocked commands + cmd_str = " ".join(args[:2]) if len(args) >= 2 else args[0] if args else "" + + for blocked in BLOCKED_GH_COMMANDS: + if cmd_str.startswith(blocked): + _b().audit_log( + "blocked_command", + "gh_execute", + success=False, + details={"command_args": args, "blocked_command": blocked}, + ) + return make_error( + f"Command '{blocked}' is not allowed through the gateway. " + f"Allowed: {', '.join(sorted(ALLOWED_GH_COMMANDS))}, api.", + status_code=403, + details={"blocked_command": blocked, "command_args": args}, + ) + + # --- Deny-by-default allowlist (parity with git_execute) --- + # A generic gh command must be on ALLOWED_GH_COMMANDS, or be `gh api` + # (further constrained below by GH_API_ALLOWED_PATHS). Anything else fails + # closed — this is what keeps credential-adjacent and otherwise + # unanticipated subcommands from executing by default. + gh_allowed, gh_cmd_key = is_gh_command_allowed(args) + if not gh_allowed: + _b().audit_log( + "gh_command_not_allowed", + "gh_execute", + success=False, + details={"command_args": args, "command_key": gh_cmd_key}, + ) + _display_key = gh_cmd_key or "(no subcommand)" + return make_error( + f"Command 'gh {_display_key}' is not permitted through the gateway. " + f"Allowed: {', '.join(sorted(ALLOWED_GH_COMMANDS))}, api.", + status_code=403, + details={"command_key": gh_cmd_key, "command_args": args}, + ) + + # --- Phase and role-based operation filtering --- + # Block operations like "issue comment" / "issue edit" when phase or role restricts them. + # Build a command string from the first 3 non-flag args for matching. + # + # Normalize past any leading -R/--repo selector before constructing the + # command string used by the phase and role filters — parity with the + # overseer block below (line 4379) and the api-path guard further down + # (line 4541). Without this, an argv like `["-R", "owner/repo", "issue", + # "comment", "1032", "--body", "..."]` keys as `"owner/repo issue + # comment"`, which doesn't fnmatch `"issue comment *"` (phase filter) + # and doesn't `startswith("issue comment")` (_BLOCKED_GH_OPS), letting + # the role/phase enforcement be bypassed entirely. The allowlist check + # above already normalizes via `find_gh_command_index`, so doing the + # same here keeps the three positional-key call sites consistent. + _filter_cmd_idx = find_gh_command_index(args) + non_flag_args = [a for a in args[_filter_cmd_idx:] if not a.startswith("-")] + gh_command_str = " ".join(non_flag_args[:3]) + + session_phase = getattr(g, "session_phase", None) + if session_phase: + try: + phase_result = filter_operation( + phase=session_phase, + operation_type=OperationType.GH, + command=gh_command_str, + ) + if not phase_result.allowed: + _b().audit_log( + "gh_execute_blocked_phase", + "gh_execute", + success=False, + details={ + "command": gh_command_str, + "phase": session_phase, + "reason": phase_result.blocked_reason, + }, + ) + return make_error( + phase_result.message, + status_code=403, + details={ + "phase": session_phase, + "blocked_reason": phase_result.blocked_reason, + }, + ) + except ValueError: + # Invalid phase value - allow for backward compat + logger.warning("Invalid session phase in gh_execute", phase=session_phase) + + # Role-based operation filtering — block agents from posting issue comments regardless of phase. + session_role = None + if hasattr(g, "session") and g.session: + _role = getattr(g.session, "agent_role", None) + if isinstance(_role, str) and _role: + session_role = _role + elif _role is not None and not isinstance(_role, str): + # Non-string agent_role — corrupted session, deny + return make_error( + "Invalid agent role type", + status_code=403, + details={"role": str(_role), "command": gh_command_str}, + ) + if session_role: + role_allowed, role_reason = check_agent_gh_operation(session_role, gh_command_str) + if not role_allowed: + _b().audit_log( + "gh_execute_blocked_agent_role", + "gh_execute", + success=False, + details={ + "command": gh_command_str, + "role": session_role, + "reason": role_reason, + }, + ) + return make_error( + role_reason, + status_code=403, + details={"role": session_role, "command": gh_command_str}, + ) + + # Issue #1962 TASK-2-2: extra guardrails for `gh issue create` + # from the overseer role. The role-level check above does NOT + # block `gh issue create` from the overseer (the operation is + # not on _OVERSEER_BLOCKED_GH_OPS) so the existing handler lets + # it through. We now layer additional defenses on top: + # repo enforcement against EGG_PIPELINE_REPO, label injection, + # title/body size limits, and a defense-in-depth secret-pattern + # scan on the body. Failure is a structured 403. + # + # The guard looks past any leading `-R`/`--repo` selector via + # `find_gh_command_index` so an argv like + # `[-R owner/repo issue create --title ... --body ]` + # still runs the secret-pattern scan; otherwise the leading + # selector would put the `"issue"` token at args[2] instead of + # args[0], so an `args[0] == "issue"` check would miss it and + # the entire overseer block would be silently skipped (parity + # fix with the api-path guard below). + _overseer_cmd_idx = find_gh_command_index(args) + if ( + session_role + and session_role.lower() == "overseer" + and _overseer_cmd_idx + 1 < len(args) + and args[_overseer_cmd_idx] == "issue" + and args[_overseer_cmd_idx + 1] == "create" + ): + try: + from ..agent_restrictions import check_overseer_gh_issue_create + except ImportError: + from agent_restrictions import ( # type: ignore[no-redef] + check_overseer_gh_issue_create, + ) + + # Parse the relevant flags from the gh argv. We accept both + # --title-file/--body-file (the new CLI verb's preferred path) + # and --title/--body (the historical form) so old callers do + # not break. Each known flag MUST be followed by a value that + # does not start with '-' (otherwise a malformed argv like + # `--repo --label foo` would consume `--label` as the repo + # value and walk past every subsequent flag — reviewer_code + # blocker against the original loop's order-dependence). + repo_arg: str | None = None + title_text: str = "" + body_text: str = "" + labels: list[str] = [] + _OVERSEER_VALUE_FLAGS = { + "--repo", + "--label", + "--title", + "--title-file", + "--body", + "--body-file", + } + + def _value_for(flag: str, idx: int) -> tuple[str | None, tuple[Response, int] | None]: + """Return (value, error_response) for a known --flag at args[idx].""" + if idx + 1 >= len(args): + return None, make_error( + f"Flag {flag!r} requires a value (end of argv)", + status_code=400, + details={"command": gh_command_str}, + ) + val = args[idx + 1] + if val.startswith("-"): + return None, make_error( + f"Flag {flag!r} requires a value (got another flag {val!r})", + status_code=400, + details={"command": gh_command_str}, + ) + return val, None + + # Start past the `issue create` tokens; `_overseer_cmd_idx` is the + # index of `"issue"`, so the flag walk begins at `_overseer_cmd_idx + # + 2`. With no leading selector this collapses to the original + # `i = 2`. + i = _overseer_cmd_idx + 2 + while i < len(args): + tok = args[i] + if tok in _OVERSEER_VALUE_FLAGS: + val, err = _value_for(tok, i) + if err is not None: + return err + if tok == "--repo": + repo_arg = val + elif tok == "--label": + labels.append(val or "") + elif tok == "--title": + title_text = val or "" + elif tok == "--title-file": + try: + with open(val or "", encoding="utf-8", errors="strict") as _f: + title_text = _f.read().strip() + except UnicodeDecodeError as _exc: + return make_error( + f"--title-file {val!r} contains invalid UTF-8: {_exc}", + status_code=400, + details={"command": gh_command_str}, + ) + except OSError as _exc: + return make_error( + f"Cannot read --title-file {val!r}: {_exc}", + status_code=400, + details={"command": gh_command_str}, + ) + elif tok == "--body": + body_text = val or "" + elif tok == "--body-file": + try: + # errors="strict" so invalid UTF-8 in the body + # is rejected loudly (reviewer_code blocker: + # silent corruption could swap a leaked-secret + # byte sequence past the regex check). + with open(val or "", encoding="utf-8", errors="strict") as _f: + body_text = _f.read() + except UnicodeDecodeError as _exc: + return make_error( + f"--body-file {val!r} contains invalid UTF-8: {_exc}", + status_code=400, + details={"command": gh_command_str}, + ) + except OSError as _exc: + return make_error( + f"Cannot read --body-file {val!r}: {_exc}", + status_code=400, + details={"command": gh_command_str}, + ) + i += 2 + continue + else: + i += 1 + + pipeline_repo = os.environ.get("EGG_PIPELINE_REPO") + ov_check = check_overseer_gh_issue_create( + role=session_role, + repo=repo_arg or "", + pipeline_repo=pipeline_repo, + labels=labels, + title=title_text, + body=body_text, + ) + if not ov_check.allowed: + _b().audit_log( + "gh_overseer_issue_create_blocked", + "gh_execute", + success=False, + details={ + "command": gh_command_str, + "role": session_role, + "reason": ov_check.reason, + "secret_kinds": list(ov_check.secret_kinds), + }, + ) + return make_error( + ov_check.reason, + status_code=403, + details={ + "role": session_role, + "command": gh_command_str, + "secret_kinds": list(ov_check.secret_kinds), + }, + ) + # Auto-inject any required labels the caller forgot. The + # injected labels are tagged in the audit log so operators can + # spot bypass attempts. + if ov_check.injected_labels: + for lbl in ov_check.injected_labels: + args = (*args, "--label", lbl) + _b().audit_log( + "gh_overseer_issue_create_labels_injected", + "gh_execute", + success=True, + details={ + "command": gh_command_str, + "role": session_role, + "injected_labels": list(ov_check.injected_labels), + }, + ) + + # For 'gh api' commands, validate the path against allowlist. + # Look past any leading -R/--repo selector so `gh -R owner/repo api /path` + # is still subjected to GH_API_ALLOWED_PATHS — otherwise the leading + # selector would shift args[0] off "api" and the path check would be + # silently skipped. + api_path: str | None = None + method: str = "GET" + _gh_cmd_idx = find_gh_command_index(args) + if _gh_cmd_idx < len(args) and args[_gh_cmd_idx] == "api" and len(args) > _gh_cmd_idx + 1: + # Parse arguments to find the actual API path (skip flags like -X, --method, etc.) + api_path, method = parse_gh_api_args(args[_gh_cmd_idx + 1 :]) + if api_path is None: + _b().audit_log( + "api_path_missing", + "gh_execute", + success=False, + details={"command_args": args}, + ) + return make_error("No API path provided in gh api command", status_code=400) + + # Resolve {owner} and {repo} template variables if present + # The gh CLI resolves these from the current repo's git remote + resolved_api_path = _b().resolve_gh_api_template_variables(api_path, cwd) + if resolved_api_path is None: + _b().audit_log( + "api_path_template_resolution_failed", + "gh_execute", + success=False, + details={ + "api_path": api_path, + "cwd": cwd, + "reason": "Could not resolve template variables", + }, + ) + return make_error( + "Could not resolve {owner}/{repo} template variables. " + "Ensure you are in a git repository with an 'origin' remote.", + status_code=400, + ) + + # If template variables were resolved, update the args to use resolved path + if resolved_api_path != api_path: + # Find and replace the API path in args + args = list(args) # Make a mutable copy + for i, arg in enumerate(args): + if arg == api_path: + args[i] = resolved_api_path + break + api_path = resolved_api_path + + path_valid, path_error = validate_gh_api_path(api_path, method) + if not path_valid: + _b().audit_log( + "api_path_blocked", + "gh_execute", + success=False, + details={"api_path": api_path, "method": method, "reason": path_error}, + ) + return make_error(path_error, status_code=403) + + # Detect issue comment/edit via gh api (bypass prevention). + # These API calls are equivalent to "gh issue comment/edit {id}" — + # apply the same phase + role checks. + synthesized_cmd = None + + # POST to repos/{owner}/{repo}/issues/{id}/comments → issue comment + _api_issue_comment_match = re.match(r"^repos/[^/]+/[^/]+/issues/(\d+)/comments$", api_path) + if _api_issue_comment_match and method.upper() == "POST": + synthesized_cmd = f"issue comment {_api_issue_comment_match.group(1)}" + + # PATCH to repos/{owner}/{repo}/issues/{id} → issue edit + _api_issue_edit_match = re.match(r"^repos/[^/]+/[^/]+/issues/(\d+)$", api_path) + if _api_issue_edit_match and method.upper() == "PATCH": + synthesized_cmd = f"issue edit {_api_issue_edit_match.group(1)}" + + if synthesized_cmd: + # Phase check + if session_phase: + try: + api_phase_result = filter_operation( + phase=session_phase, + operation_type=OperationType.GH, + command=synthesized_cmd, + ) + if not api_phase_result.allowed: + _b().audit_log( + "gh_api_issue_op_blocked_phase", + "gh_execute", + success=False, + details={ + "api_path": api_path, + "synthesized_command": synthesized_cmd, + "phase": session_phase, + }, + ) + return make_error( + api_phase_result.message, + status_code=403, + details={ + "phase": session_phase, + "blocked_reason": api_phase_result.blocked_reason, + }, + ) + except ValueError: + pass + # Role check + if session_role: + api_role_allowed, api_role_reason = check_agent_gh_operation( + session_role, synthesized_cmd + ) + if not api_role_allowed: + _b().audit_log( + "gh_api_issue_op_blocked_role", + "gh_execute", + success=False, + details={ + "api_path": api_path, + "role": session_role, + "reason": api_role_reason, + }, + ) + return make_error( + api_role_reason, + status_code=403, + details={"role": session_role, "api_path": api_path}, + ) + + # Extract repo using comprehensive extractor (handles --repo, gh repo *, gh api paths) + repo = extract_repo_from_gh_command(args) + + # Fall back to payload_repo if command doesn't contain repo + if not repo and payload_repo: + repo = payload_repo + # Inject --repo into args so gh command uses it + # NOTE: Don't inject for commands that don't support --repo flag: + # - 'gh repo' commands - they take repo as positional arg + # - 'gh auth' commands - global commands, no repo context + # - 'gh config' commands - global commands, no repo context + # - 'gh api' commands - repo is in the API path, not a flag + commands_without_repo_flag = {"repo", "auth", "config", "api"} + if args and args[0] not in commands_without_repo_flag: + args = ["--repo", payload_repo] + list(args) + + # Determine auth mode (default to bot if repo not specified) + auth_mode = _b().get_auth_mode(repo) if repo else "bot" + + # Check Private Repo Mode policy (if enabled and repo is known) + if repo: + repo_info = parse_owner_repo(repo) + if repo_info: + priv_result = _b().check_private_repo_access( + operation="gh_execute", + owner=repo_info.owner, + repo=repo_info.repo, + for_write=False, # Assume read for generic gh execute + session_mode=session_mode, + ) + if not priv_result.allowed: + _b().audit_log( + "gh_execute_denied_private_mode", + "gh_execute", + success=False, + details={ + "repo": repo, + "command_args": args[:3] if len(args) > 3 else args, + "reason": priv_result.reason, + "visibility": priv_result.visibility, + "auth_mode": auth_mode, + }, + ) + return make_error( + priv_result.reason, + status_code=403, + details=priv_result.to_dict(), + ) + + # Use reviewer token for PR reviews when available. This allows the + # reviewer bot (a separate GitHub App) to post approve/request-changes + # on PRs authored by the main bot — something the bot can't do on its own PRs. + # This applies to both bot and user modes since the reviewer token is a + # separate identity specifically for reviews. + # Note: args may have "--repo owner/repo" prepended, so we check if "pr" and "review" + # appear in sequence anywhere in the args (not just at positions 0 and 1). + def is_pr_review_command(cmd_args: list[str]) -> bool: + for i in range(len(cmd_args) - 1): + if cmd_args[i] == "pr" and cmd_args[i + 1] == "review": + return True + return False + + if is_pr_review_command(args) and auth_mode in ("bot", "user"): + try: + from token_refresher import is_reviewer_token_available + + if is_reviewer_token_available(): + auth_mode = "reviewer" + logger.info("Using reviewer token for pr review command") + else: + logger.debug( + "Reviewer token not available, using %s token for pr review", auth_mode + ) + except ImportError: + pass + + # For mutating operations on specific resources via gh api, verify ownership + if api_path is not None: + policy = _b().get_policy_engine() + + # PATCH on comment endpoints — verify bot/configured user owns the comment + comment_info = extract_comment_edit_info(api_path, method) + if comment_info: + c_owner, c_repo_name, c_comment_id, c_comment_type = comment_info + ownership_result = policy.check_comment_ownership( + f"{c_owner}/{c_repo_name}", + c_comment_id, + c_comment_type, + auth_mode=auth_mode, + ) + if not ownership_result.allowed: + _b().audit_log( + "comment_edit_denied", + "gh_execute", + success=False, + details={ + "api_path": api_path, + "comment_id": c_comment_id, + "comment_type": c_comment_type, + "reason": ownership_result.reason, + }, + ) + return make_error( + ownership_result.reason, + status_code=403, + details=ownership_result.to_dict(), + ) + + # POST/PATCH on issue labels — verify bot/configured user owns the issue/PR + label_info = extract_issue_label_info(api_path, method) + if label_info: + l_owner, l_repo_name, l_issue_number = label_info + ownership_result = policy.check_issue_ownership( + f"{l_owner}/{l_repo_name}", + l_issue_number, + auth_mode=auth_mode, + ) + if not ownership_result.allowed: + _b().audit_log( + "label_edit_denied", + "gh_execute", + success=False, + details={ + "api_path": api_path, + "issue_number": l_issue_number, + "reason": ownership_result.reason, + }, + ) + return make_error( + ownership_result.reason, + status_code=403, + details=ownership_result.to_dict(), + ) + + # POST on PR requested reviewers — verify bot/configured user owns the PR + reviewer_info = extract_pr_reviewer_info(api_path, method) + if reviewer_info: + r_owner, r_repo_name, r_pr_number = reviewer_info + ownership_result = policy.check_pr_ownership( + f"{r_owner}/{r_repo_name}", + r_pr_number, + auth_mode=auth_mode, + ) + if not ownership_result.allowed: + _b().audit_log( + "reviewer_edit_denied", + "gh_execute", + success=False, + details={ + "api_path": api_path, + "pr_number": r_pr_number, + "reason": ownership_result.reason, + }, + ) + return make_error( + ownership_result.reason, + status_code=403, + details=ownership_result.to_dict(), + ) + + # POST on PR reviews — verify PR exists and review is allowed + review_info = extract_pr_review_info(api_path, method) + if review_info: + rv_owner, rv_repo_name, rv_pr_number = review_info + review_result = policy.check_pr_review_allowed( + f"{rv_owner}/{rv_repo_name}", + rv_pr_number, + auth_mode=auth_mode, + ) + if not review_result.allowed: + _b().audit_log( + "review_create_denied", + "gh_execute", + success=False, + details={ + "api_path": api_path, + "pr_number": rv_pr_number, + "reason": review_result.reason, + }, + ) + return make_error( + review_result.reason, + status_code=403, + details=review_result.to_dict(), + ) + + # Execute the command + github = _b().get_github_client(mode=auth_mode) + result = github.execute(args, timeout=60, cwd=cwd, mode=auth_mode) + + if result.success: + response_data = result.to_dict() + response_data["auth_mode"] = auth_mode + return make_success("Command executed", response_data) + else: + return make_error( + f"Command failed: {result.stderr}", + status_code=500, + details=result.to_dict(), + ) diff --git a/gateway/gateway/_gh_ops.py b/gateway/gateway/_gh_ops.py new file mode 100644 index 0000000000..42b31b58d4 --- /dev/null +++ b/gateway/gateway/_gh_ops.py @@ -0,0 +1,1083 @@ +"""Gateway gh_ops cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from flask import Response, g, request + +try: + from ..github_client import ( + GitHubClient, + ) + from ..phase_filter import ( + OperationType, + filter_operation, + ) + from ..repo_parser import ( + OWNER_REPO_PATTERN, + parse_owner_repo, + ) +except ImportError: # flat/container import mode + from github_client import ( # type: ignore[no-redef, import-untyped] + GitHubClient, + ) + from phase_filter import ( # type: ignore[no-redef, import-untyped] + OperationType, + filter_operation, + ) + from repo_parser import ( # type: ignore[no-redef, import-untyped] + OWNER_REPO_PATTERN, + parse_owner_repo, + ) + +from ._helpers import make_error, make_success + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + + +def _apply_pr_labels( + github: GitHubClient, + repo: str, + stdout: str, + auth_mode: str, + agent_role: str | None, + pipeline_id: str | None, +) -> None: + """Apply labels to a newly created PR. Failures are logged but non-fatal.""" + if not pipeline_id: + return + + # Extract PR number from URL like https://github.com/owner/repo/pull/42 + match = re.search(r"/pull/(\d+)", stdout or "") + if not match: + return + + pr_number = match.group(1) + labels = ["egg"] + if agent_role: + labels.append(f"agent:{agent_role}") + + try: + # Ensure labels exist (idempotent) + for label in labels: + github.execute( + ["label", "create", label, "--force", "--repo", repo], + timeout=15, + mode=auth_mode, + ) + # Apply labels to the PR + label_args = ["issue", "edit", pr_number, "--repo", repo] + for label in labels: + label_args.extend(["--add-label", label]) + github.execute(label_args, timeout=15, mode=auth_mode) + except Exception: + logger.warning( + "Failed to apply labels to PR", + pr_number=pr_number, + repo=repo, + labels=labels, + exc_info=True, + ) + + +def gh_pr_create() -> tuple[Response, int] | Response: + """ + Create a pull request. + + Request body: + { + "repo": "owner/repo", + "title": "PR title", + "body": "PR body", + "base": "main", + "head": "feature-branch", + "draft": false (optional, forced to true in user mode) + } + + Policy: + - Bot mode: allowed (egg can create PRs) + - User mode: allowed (PRs are forced to draft mode) + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + repo = data.get("repo") + title = data.get("title") + body = data.get("body", "") + base = data.get("base") # None = gh uses repo's default branch + head = data.get("head") + + if not repo: + return make_error("Missing repo") + if not title: + return make_error("Missing title") + if not head: + return make_error("Missing head branch") + + # Determine auth mode for this repo + auth_mode = _b().get_auth_mode(repo) + + # Get session mode from request context (set by @require_session_auth decorator) + session_mode = getattr(g, "session_mode", None) + + # Get session phase from request context (set by @require_session_auth decorator) + session_phase = getattr(g, "session_phase", None) + + # Check phase restrictions (if session has a phase set) + if session_phase: + try: + phase_result = filter_operation( + phase=session_phase, + operation_type=OperationType.GH, + command="pr create", + ) + if not phase_result.allowed: + _b().audit_log( + "pr_create_blocked_phase", + "gh_pr_create", + success=False, + details={ + "repo": repo, + "phase": session_phase, + "reason": phase_result.blocked_reason, + }, + ) + return make_error( + phase_result.message, + status_code=403, + details={ + "phase": session_phase, + "blocked_reason": phase_result.blocked_reason, + }, + ) + except ValueError as e: + # Invalid phase value - log warning and allow (backward compat) + logger.warning( + "Invalid session phase value", + phase=session_phase, + error=str(e), + ) + else: + # No phase set - allow by default for backward compatibility + # Log a warning to track sessions without phase + logger.debug( + "PR create request from session without phase (backward compat)", + repo=repo, + ) + + # Check Private Repo Mode policy (if enabled) + repo_info = parse_owner_repo(repo) + if repo_info: + priv_result = _b().check_private_repo_access( + operation="pr_create", + owner=repo_info.owner, + repo=repo_info.repo, + for_write=True, + session_mode=session_mode, + ) + if not priv_result.allowed: + _b().audit_log( + "pr_create_denied_private_mode", + "gh_pr_create", + success=False, + details={ + "repo": repo, + "reason": priv_result.reason, + "visibility": priv_result.visibility, + "auth_mode": auth_mode, + }, + ) + return make_error( + priv_result.reason, + status_code=403, + details=priv_result.to_dict(), + ) + + # Policy check: PR creation may be blocked in reviewer mode + policy = _b().get_policy_engine() + policy_result = policy.check_pr_create_allowed(repo, auth_mode=auth_mode) + if not policy_result.allowed: + _b().audit_log( + "pr_create_blocked", + "gh_pr_create", + success=False, + details={ + "repo": repo, + "reason": policy_result.reason, + "auth_mode": auth_mode, + }, + ) + return make_error( + policy_result.reason, + status_code=403, + details=policy_result.details, + ) + + # In user mode, force PRs to be created as drafts + draft = data.get("draft", False) + if policy_result.details and policy_result.details.get("force_draft"): + draft = True + + # Inject machine-parseable pipeline metadata as an HTML comment so + # downstream tooling (status reporters, audit scrapers) can recover + # the pipeline_id / agent_role / issue from the PR body without + # round-tripping through the orchestrator state store. + session = getattr(g, "session", None) + session_pipeline_id = getattr(session, "pipeline_id", None) if session else None + if session_pipeline_id: + session_agent_role = getattr(session, "agent_role", None) or "" + session_issue_number = getattr(session, "issue_number", None) or "" + + # Sanitize values to prevent breaking the HTML comment structure + def _safe(v: str) -> str: + return str(v).replace("--", "").replace(">", "") + + metadata_comment = ( + f"" + ) + body = f"{body}\n\n{metadata_comment}" if body else metadata_comment + + try: + github = _b().get_github_client(mode=auth_mode) + args = [ + "pr", + "create", + "--repo", + repo, + "--title", + title, + "--body", + body, + "--head", + head, + ] + + if base: + args.extend(["--base", base]) + + if draft: + args.append("--draft") + + result = github.execute(args, timeout=60, mode=auth_mode) + + if result.success: + # Apply labels to the newly created PR + _apply_pr_labels( + github=github, + repo=repo, + stdout=result.stdout, + auth_mode=auth_mode, + agent_role=getattr(session, "agent_role", None) if session else None, + pipeline_id=session_pipeline_id, + ) + + _b().audit_log( + "pr_created", + "gh_pr_create", + success=True, + details={ + "repo": repo, + "title": title, + "base": base, + "head": head, + "auth_mode": auth_mode, + "draft": draft, + }, + ) + return make_success( + "PR created", + {"stdout": result.stdout, "stderr": result.stderr, "auth_mode": auth_mode}, + ) + else: + error_msg = result.stderr or "Unknown error" + _b().audit_log( + "pr_create_failed", + "gh_pr_create", + success=False, + details={ + "repo": repo, + "error": error_msg[:200] if error_msg else "", + "auth_mode": auth_mode, + }, + ) + return make_error( + f"Failed to create PR: {error_msg}", + status_code=500, + details=result.to_dict(), + ) + except Exception as e: + logger.exception("Unexpected error in gh_pr_create") + return make_error(f"Internal error: {e}", status_code=500) + + +def gh_pr_comment() -> tuple[Response, int] | Response: + """ + Add a comment to a PR. + + Request body: + { + "repo": "owner/repo", + "pr_number": 123, + "body": "Comment text" + } + + Policy: pr_comment (allowed on any PR) + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + repo = data.get("repo") + pr_number = data.get("pr_number") + body = data.get("body") + + if not repo: + return make_error("Missing repo") + if not pr_number: + return make_error("Missing pr_number") + if not body: + return make_error("Missing body") + + # Determine auth mode for this repo + auth_mode = _b().get_auth_mode(repo) + + # Get session mode from request context (set by @require_session_auth decorator) + session_mode = getattr(g, "session_mode", None) + + # Check Private Repo Mode policy (if enabled) + repo_info = parse_owner_repo(repo) + if repo_info: + priv_result = _b().check_private_repo_access( + operation="pr_comment", + owner=repo_info.owner, + repo=repo_info.repo, + for_write=True, + session_mode=session_mode, + ) + if not priv_result.allowed: + _b().audit_log( + "pr_comment_denied_private_mode", + "gh_pr_comment", + success=False, + details={ + "repo": repo, + "pr_number": pr_number, + "reason": priv_result.reason, + "visibility": priv_result.visibility, + "auth_mode": auth_mode, + }, + ) + return make_error( + priv_result.reason, + status_code=403, + details=priv_result.to_dict(), + ) + + # Check if commenting is allowed (allowed on any PR) + policy = _b().get_policy_engine() + policy_result = policy.check_pr_comment_allowed(repo, pr_number, auth_mode=auth_mode) + + if not policy_result.allowed: + _b().audit_log( + "pr_comment_denied", + "gh_pr_comment", + success=False, + details={ + "repo": repo, + "pr_number": pr_number, + "reason": policy_result.reason, + "auth_mode": auth_mode, + }, + ) + return make_error( + f"Comment denied: {policy_result.reason}", + status_code=403, + details=policy_result.details, + ) + + github = _b().get_github_client(mode=auth_mode) + args = [ + "pr", + "comment", + str(pr_number), + "--repo", + repo, + "--body", + body, + ] + + result = github.execute(args, timeout=30, mode=auth_mode) + + if result.success: + _b().audit_log( + "pr_comment_added", + "gh_pr_comment", + success=True, + details={"repo": repo, "pr_number": pr_number, "auth_mode": auth_mode}, + ) + return make_success("Comment added", {"stdout": result.stdout, "auth_mode": auth_mode}) + else: + return make_error( + f"Failed to add comment: {result.stderr}", + status_code=500, + details=result.to_dict(), + ) + + +def gh_pr_edit() -> tuple[Response, int] | Response: + """ + Edit a PR title, body, or base branch. + + Request body: + { + "repo": "owner/repo", + "pr_number": 123, + "title": "New title", # optional + "body": "New body", # optional + "base": "main" # optional — retarget the PR base + } + + At least one of ``title``, ``body``, or ``base`` must be set. + + The ``base`` field is the merge target branch ref (e.g. + ``main`` or ``egg/issue-N/slice-3``). It is the canonical + surface for the stacked-PR reconciler (#2137) to retarget a + child PR after the parent merges and the parent's branch is + deleted on origin. The ref is forwarded as-is to the GitHub + PATCH ``/repos/{owner}/{repo}/pulls/{pr_number}`` API. + + Policy: pr_ownership + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + repo = data.get("repo") + pr_number = data.get("pr_number") + title = data.get("title") + body = data.get("body") + base = data.get("base") + + if not repo: + return make_error("Missing repo") + if not pr_number: + return make_error("Missing pr_number") + if isinstance(pr_number, bool) or not isinstance(pr_number, int) or pr_number < 1: + return make_error("Invalid pr_number: must be a positive integer") + if not title and not body and not base: + return make_error("Must provide title, body, or base to edit") + if base is not None and (not isinstance(base, str) or not base.strip()): + return make_error("Invalid base: must be a non-empty branch ref") + + # Validate repo format early (before any API calls) + repo_info = parse_owner_repo(repo) + if not repo_info: + return make_error("Invalid repo format: expected 'owner/repo'") + + # Determine auth mode for this repo + auth_mode = _b().get_auth_mode(repo) + + # Get session mode from request context (set by @require_session_auth decorator) + session_mode = getattr(g, "session_mode", None) + + # Check Private Repo Mode policy (if enabled) + priv_result = _b().check_private_repo_access( + operation="pr_edit", + owner=repo_info.owner, + repo=repo_info.repo, + for_write=True, + session_mode=session_mode, + ) + if not priv_result.allowed: + _b().audit_log( + "pr_edit_denied_private_mode", + "gh_pr_edit", + success=False, + details={ + "repo": repo, + "pr_number": pr_number, + "reason": priv_result.reason, + "visibility": priv_result.visibility, + "auth_mode": auth_mode, + }, + ) + return make_error( + priv_result.reason, + status_code=403, + details=priv_result.to_dict(), + ) + + # Check PR ownership (pass auth mode for relaxed policy in user mode) + policy = _b().get_policy_engine() + policy_result = policy.check_pr_ownership(repo, pr_number, auth_mode=auth_mode) + + if not policy_result.allowed: + _b().audit_log( + "pr_edit_denied", + "gh_pr_edit", + success=False, + details={ + "repo": repo, + "pr_number": pr_number, + "reason": policy_result.reason, + "auth_mode": auth_mode, + }, + ) + return make_error( + f"Edit denied: {policy_result.reason}", + status_code=403, + details=policy_result.details, + ) + + github = _b().get_github_client(mode=auth_mode) + args = ["api", f"repos/{repo_info.owner}/{repo_info.repo}/pulls/{pr_number}", "-X", "PATCH"] + if title: + args.extend(["-f", f"title={title}"]) + if body: + args.extend(["-f", f"body={body}"]) + if base: + args.extend(["-f", f"base={base}"]) + + result = github.execute(args, timeout=30, mode=auth_mode) + + if result.success: + _b().audit_log( + "pr_edited", + "gh_pr_edit", + success=True, + details={"repo": repo, "pr_number": pr_number, "auth_mode": auth_mode}, + ) + return make_success("PR edited", {"stdout": result.stdout, "auth_mode": auth_mode}) + else: + return make_error( + f"Failed to edit PR: {result.stderr}", + status_code=500, + details=result.to_dict(), + ) + + +def gh_pr_close() -> tuple[Response, int] | Response: + """ + Close a PR. + + Request body: + { + "repo": "owner/repo", + "pr_number": 123 + } + + Policy: pr_ownership + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + repo = data.get("repo") + pr_number = data.get("pr_number") + + if not repo: + return make_error("Missing repo") + if not pr_number: + return make_error("Missing pr_number") + + # Determine auth mode for this repo + auth_mode = _b().get_auth_mode(repo) + + # Get session mode from request context (set by @require_session_auth decorator) + session_mode = getattr(g, "session_mode", None) + + # Check Private Repo Mode policy (if enabled) + repo_info = parse_owner_repo(repo) + if repo_info: + priv_result = _b().check_private_repo_access( + operation="pr_close", + owner=repo_info.owner, + repo=repo_info.repo, + for_write=True, + session_mode=session_mode, + ) + if not priv_result.allowed: + _b().audit_log( + "pr_close_denied_private_mode", + "gh_pr_close", + success=False, + details={ + "repo": repo, + "pr_number": pr_number, + "reason": priv_result.reason, + "visibility": priv_result.visibility, + "auth_mode": auth_mode, + }, + ) + return make_error( + priv_result.reason, + status_code=403, + details=priv_result.to_dict(), + ) + + # Check PR ownership (pass auth mode for relaxed policy in user mode) + policy = _b().get_policy_engine() + policy_result = policy.check_pr_ownership(repo, pr_number, auth_mode=auth_mode) + + if not policy_result.allowed: + _b().audit_log( + "pr_close_denied", + "gh_pr_close", + success=False, + details={ + "repo": repo, + "pr_number": pr_number, + "reason": policy_result.reason, + "auth_mode": auth_mode, + }, + ) + return make_error( + f"Close denied: {policy_result.reason}", + status_code=403, + details=policy_result.details, + ) + + github = _b().get_github_client(mode=auth_mode) + args = ["pr", "close", str(pr_number), "--repo", repo] + + result = github.execute(args, timeout=30, mode=auth_mode) + + if result.success: + _b().audit_log( + "pr_closed", + "gh_pr_close", + success=True, + details={"repo": repo, "pr_number": pr_number, "auth_mode": auth_mode}, + ) + return make_success("PR closed", {"stdout": result.stdout, "auth_mode": auth_mode}) + else: + return make_error( + f"Failed to close PR: {result.stderr}", + status_code=500, + details=result.to_dict(), + ) + + +def gh_find_open_pr() -> tuple[Response, int] | Response: + """Control-plane idempotency lookup: return the open ``head → base`` PR number. + + This is an **orchestrator-only** route, gated by ``@require_launcher_auth`` + rather than ``@require_session_auth``: the caller is the control plane + (the orchestrator holds the launcher secret), not a sandboxed agent. It + exists so the orchestrator's slice-PR idempotency pre-flight (#2777 cq-8) + does not have to register a synthetic *agent* session and impersonate a + role on ``/api/v1/gh/execute`` — the conflation that #2893 papered over by + adding a bogus ``AgentRole.ORCHESTRATOR``. The orchestrator is not an + agent role; it is the server that manages pipelines, so it authenticates + as the control plane and uses a purpose-built read-only endpoint. + + Unlike ``/api/v1/gh/execute`` (arbitrary allowlisted argv), this route + accepts only ``repo``/``head``/``base`` and constructs the fixed + read-only argv server-side, so there is no general gh-command surface on + the launcher-auth path. + + Request body: + {"repo": "owner/name", "head": "", "base": ""} + + Returns: + ``{"number": }`` on hit, ``{"number": null}`` on miss. The GH + API documents at most one open PR per (head, base) tuple, so the + lookup is ``--limit 1``. + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + # Validate and bind stripped values in one pass so mypy sees ``repo`` + # / ``head`` / ``base`` as ``str`` (not the ``Any`` returned by + # ``data.get(...)``) below. + fields: dict[str, str] = {} + for name, value in ( + ("repo", data.get("repo")), + ("head", data.get("head")), + ("base", data.get("base")), + ): + if not isinstance(value, str) or not value.strip(): + return make_error(f"Missing or invalid {name}: must be a non-empty string") + fields[name] = value.strip() + repo, head, base = fields["repo"], fields["head"], fields["base"] + + # ``OWNER_REPO_PATTERN`` is stricter than ``parse_owner_repo`` (which + # also accepts full GitHub URLs); the docstring and the validation + # error below both promise the literal ``owner/name`` shape, so we + # match against the pattern directly rather than the URL-permissive + # helper. + if OWNER_REPO_PATTERN.match(repo) is None: + return make_error("Invalid repo: must be 'owner/name'") + + args = [ + "pr", + "list", + "--repo", + repo, + "--head", + head, + "--base", + base, + "--state", + "open", + "--limit", + "1", + "--json", + "number", + ] + + auth_mode = _b().get_auth_mode(repo) + github = _b().get_github_client(mode=auth_mode) + result = github.execute(args, timeout=60, mode=auth_mode) + + if not result.success: + # ``gh`` should not print credentials to stderr, but truncate + # defensively so we never page a giant stderr blob into the + # audit log. + stderr_excerpt = (result.stderr or "")[:500] + _b().audit_log( + "gh_find_open_pr_failed", + "gh_find_open_pr", + success=False, + details={"repo": repo, "head": head, "base": base, "stderr": stderr_excerpt}, + ) + return make_error( + f"Command failed: {result.stderr}", + status_code=500, + details=result.to_dict(), + ) + + number: int | None = None + stdout = (result.stdout or "").strip() + if stdout: + try: + items = json.loads(stdout) + except ValueError, TypeError: + items = None + if isinstance(items, list): + for item in items: + if isinstance(item, dict) and item.get("number") is not None: + try: + number = int(item["number"]) + except TypeError, ValueError: + number = None + break + + _b().audit_log( + "gh_find_open_pr", + "gh_find_open_pr", + success=True, + details={"repo": repo, "head": head, "base": base, "number": number}, + ) + return make_success("Open PR lookup complete", {"number": number}) + + +def gh_list_open_prs() -> tuple[Response, int] | Response: + """Control-plane listing: return the repo's open PRs (number/head/base). + + Like ``/api/v1/gh/find_open_pr``, this is an **orchestrator-only** + route gated by ``@require_launcher_auth`` rather than + ``@require_session_auth``: the caller is the control plane (the + orchestrator holds the launcher secret), not a sandboxed agent. It + exists so the orchestrator's context-PR idempotency pre-flight + (``_open_context_pr_at_implement_start``) and stacked-PR reconciler + do not have to register a synthetic *agent* session and impersonate a + role on ``/api/v1/gh/execute`` — the conflation #2910 papered over by + adding a bogus ``AgentRole.ORCHESTRATOR`` (removed in #2925). The + orchestrator is not an agent role; it is the server that manages + pipelines, so it authenticates as the control plane and uses a + purpose-built read-only endpoint. + + Unlike ``/api/v1/gh/execute`` (arbitrary allowlisted argv), this route + accepts only ``repo``/``limit`` and constructs the fixed read-only + argv server-side, so there is no general gh-command surface on the + launcher-auth path. + + Request body: + {"repo": "owner/name", "limit": } + + Returns: + ``{"prs": [{"number": int, "headRefName": str, "baseRefName": str}, ...]}``. + The caller (``GatewayClient.list_open_prs``) normalises this into + the ``number``/``head_ref``/``base_ref`` shape its consumers expect. + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + # ``request.get_json()`` returns whatever JSON parses — a launcher + # caller could legitimately post an array or scalar. Reject anything + # other than an object up front so ``data.get(...)`` below cannot + # raise ``AttributeError`` → 500. + if not isinstance(data, dict): + return make_error("Invalid body: must be a JSON object") + + repo = data.get("repo") + if not isinstance(repo, str) or not repo.strip(): + return make_error("Missing or invalid repo: must be a non-empty string") + repo = repo.strip() + + # ``OWNER_REPO_PATTERN`` is stricter than ``parse_owner_repo`` (which + # also accepts full GitHub URLs); the docstring promises the literal + # ``owner/name`` shape, so we match against the pattern directly. + if OWNER_REPO_PATTERN.match(repo) is None: + return make_error("Invalid repo: must be 'owner/name'") + + # ``bool`` is a subclass of ``int``; reject it explicitly so ``True`` + # cannot slip through as ``limit=1``. + limit = data.get("limit", 200) + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000: + return make_error("Invalid limit: must be an integer in [1, 1000]") + + args = [ + "pr", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + str(limit), + "--json", + "number,headRefName,baseRefName", + ] + + auth_mode = _b().get_auth_mode(repo) + github = _b().get_github_client(mode=auth_mode) + result = github.execute(args, timeout=60, mode=auth_mode) + + if not result.success: + # ``gh`` should not print credentials to stderr, but truncate + # defensively so we never page a giant stderr blob into the + # audit log. + stderr_excerpt = (result.stderr or "")[:500] + _b().audit_log( + "gh_list_open_prs_failed", + "gh_list_open_prs", + success=False, + details={"repo": repo, "limit": limit, "stderr": stderr_excerpt}, + ) + return make_error( + f"Command failed: {result.stderr}", + status_code=500, + details=result.to_dict(), + ) + + prs: list[dict[str, Any]] = [] + stdout = (result.stdout or "").strip() + if stdout: + try: + items = json.loads(stdout) + except ValueError, TypeError: + items = None + if isinstance(items, list): + prs = [item for item in items if isinstance(item, dict)] + + _b().audit_log( + "gh_list_open_prs", + "gh_list_open_prs", + success=True, + details={"repo": repo, "limit": limit, "count": len(prs)}, + ) + return make_success("Open PR list complete", {"prs": prs}) + + +def gh_pr_merge_state() -> tuple[Response, int] | Response: + """Control-plane PR merge-state read: return ``state`` + ``mergedAt`` (#3393). + + An **orchestrator-only** route gated by ``@require_launcher_auth`` + rather than ``@require_session_auth`` — the caller is the control + plane (the orchestrator holds the launcher secret), not a sandboxed + agent. It is the read half of the cq-1 cross-repo merge-sequencing + gate: the orchestrator polls an upstream slice PR's merge state to + decide when to mark a downstream draft PR ready. Modelled on + ``gh_find_open_pr`` / ``gh_list_open_prs`` (#2925): the orchestrator + is the server that manages pipelines, not an ``AgentRole``, so it + authenticates as the control plane and uses a purpose-built, + fixed-argv read-only endpoint (no general gh surface here). + + Merge detection deliberately keys off the PR's ``mergedAt`` / + ``state`` — NOT head-SHA equality: a squash/rebase merge produces a + merge-commit SHA that differs from the PR head, so a SHA comparison + would misfire (#3393 task-5-1 pin (a)). + + Request body: + {"repo": "owner/name", "pr_number": } + + Returns: + ``{"state": "OPEN|CLOSED|MERGED"|null, "mergedAt": ""|null}``. + """ + data = request.get_json() + if not data or not isinstance(data, dict): + return make_error("Invalid body: must be a JSON object") + + repo = data.get("repo") + if not isinstance(repo, str) or not repo.strip(): + return make_error("Missing or invalid repo: must be a non-empty string") + repo = repo.strip() + if OWNER_REPO_PATTERN.match(repo) is None: + return make_error("Invalid repo: must be 'owner/name'") + + # ``bool`` is a subclass of ``int``; reject it explicitly so ``True`` + # cannot slip through as ``pr_number=1``. + pr_number = data.get("pr_number") + if isinstance(pr_number, bool) or not isinstance(pr_number, int) or pr_number < 1: + return make_error("Invalid pr_number: must be a positive integer") + + args = [ + "pr", + "view", + str(pr_number), + "--repo", + repo, + "--json", + "state,mergedAt", + ] + + auth_mode = _b().get_auth_mode(repo) + github = _b().get_github_client(mode=auth_mode) + result = github.execute(args, timeout=60, mode=auth_mode) + + if not result.success: + stderr_excerpt = (result.stderr or "")[:500] + _b().audit_log( + "gh_pr_merge_state_failed", + "gh_pr_merge_state", + success=False, + details={"repo": repo, "pr_number": pr_number, "stderr": stderr_excerpt}, + ) + return make_error( + f"Command failed: {result.stderr}", + status_code=500, + details=result.to_dict(), + ) + + state_val: Any = None + merged_at: Any = None + stdout = (result.stdout or "").strip() + if stdout: + try: + parsed = json.loads(stdout) + except ValueError, TypeError: + parsed = None + if isinstance(parsed, dict): + state_val = parsed.get("state") + merged_at = parsed.get("mergedAt") + + _b().audit_log( + "gh_pr_merge_state", + "gh_pr_merge_state", + success=True, + details={"repo": repo, "pr_number": pr_number, "state": state_val}, + ) + return make_success( + "PR merge-state lookup complete", + {"state": state_val, "mergedAt": merged_at}, + ) + + +def gh_pr_ready() -> tuple[Response, int] | Response: + """Control-plane PR draft→ready transition: wrap ``gh pr ready`` (#3393). + + An **orchestrator-only** route gated by ``@require_launcher_auth`` — + the write half of the cq-1 cross-repo merge-sequencing gate. When the + upstream slice PR merges, the orchestrator transitions the downstream + cross-repo dependent PR from draft to ready. Like the sibling + control-plane PR routes (``gh_find_open_pr`` / ``gh_list_open_prs``), + the caller is the control plane, so it authenticates with the + launcher secret and this route constructs a **fixed, narrow argv** + server-side (``pr ready --repo ``) — there is no arbitrary + gh-command surface on the launcher-auth path, only this single + ready-transition. ``pr ready`` is already on ``ALLOWED_GH_COMMANDS`` + (github_client.py) so the underlying ``gh`` invocation re-validates + through the same allowlist floor. + + Request body: + {"repo": "owner/name", "pr_number": } + + Returns: + ``{"stdout": ""}`` on success. + """ + data = request.get_json() + if not data or not isinstance(data, dict): + return make_error("Invalid body: must be a JSON object") + + repo = data.get("repo") + if not isinstance(repo, str) or not repo.strip(): + return make_error("Missing or invalid repo: must be a non-empty string") + repo = repo.strip() + if OWNER_REPO_PATTERN.match(repo) is None: + return make_error("Invalid repo: must be 'owner/name'") + + pr_number = data.get("pr_number") + if isinstance(pr_number, bool) or not isinstance(pr_number, int) or pr_number < 1: + return make_error("Invalid pr_number: must be a positive integer") + + args = [ + "pr", + "ready", + str(pr_number), + "--repo", + repo, + ] + + auth_mode = _b().get_auth_mode(repo) + github = _b().get_github_client(mode=auth_mode) + result = github.execute(args, timeout=60, mode=auth_mode) + + if not result.success: + stderr_excerpt = (result.stderr or "")[:500] + _b().audit_log( + "gh_pr_ready_failed", + "gh_pr_ready", + success=False, + details={"repo": repo, "pr_number": pr_number, "stderr": stderr_excerpt}, + ) + return make_error( + f"Failed to mark PR ready: {result.stderr}", + status_code=500, + details=result.to_dict(), + ) + + _b().audit_log( + "gh_pr_ready", + "gh_pr_ready", + success=True, + details={"repo": repo, "pr_number": pr_number}, + ) + return make_success("PR marked ready", {"stdout": result.stdout}) diff --git a/gateway/gateway/_git_execute.py b/gateway/gateway/_git_execute.py new file mode 100644 index 0000000000..26bf5e15e0 --- /dev/null +++ b/gateway/gateway/_git_execute.py @@ -0,0 +1,800 @@ +"""Gateway git_execute cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import os +import subprocess +from typing import Any + +from flask import Response, g, request + +try: + from ..git_client import ( + GIT_ALLOWED_COMMANDS, + extract_reset_target_ref, + git_cmd, + is_branch_switch, + is_branch_switching_operation, + is_repos_parent_directory, + validate_git_args, + ) +except ImportError: # flat/container import mode + from git_client import ( # type: ignore[no-redef, import-untyped] + GIT_ALLOWED_COMMANDS, + extract_reset_target_ref, + git_cmd, + is_branch_switch, + is_branch_switching_operation, + is_repos_parent_directory, + validate_git_args, + ) + +from ._git_ops import _detached_head_hint +from ._helpers import make_error, make_success, make_worktree_not_found_error + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + + +def git_execute() -> tuple[Response, int] | Response: + """ + Execute a git command in the gateway's worktree. + + This is the primary endpoint for all git operations in the gateway-managed + worktree architecture. The container has no direct git access (its .git is + shadowed by tmpfs), so all git commands route through this endpoint. + + Request body: + { + "repo_path": "/home/egg/repos/myrepo", + "operation": "status", + "args": ["--porcelain"], + "container_id": "egg-xxx" # For path mapping + } + + Supported operations: status, add, commit, log, diff, show, branch, + checkout, switch, reset, restore, stash, merge, rebase, cherry-pick, + tag, clean, config, rev-parse, remote, apply, format-patch + + Network operations (push, fetch, ls-remote) should use dedicated endpoints. + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + repo_path = data.get("repo_path") + operation = data.get("operation") + args = data.get("args", []) + container_id = data.get("container_id") + + if not repo_path: + return make_error("Missing repo_path") + if not operation: + return make_error("Missing operation") + + # Validate repo_path + path_valid, path_error = _b().validate_repo_path(repo_path) + if not path_valid: + _b().audit_log( + "git_execute_blocked", + operation, + success=False, + details={ + "repo_path": repo_path, + "git_args": args, + "container_id": container_id, + "reason": path_error, + }, + ) + return make_error(path_error, status_code=403) + + # Check if this is a "repos parent" directory (contains repos but isn't one) + # Git operations in these directories are expected to fail - this is commonly + # caused by tools like Claude Code running `git rev-parse` to detect if they're + # in a repo. Return a clear error without logging a warning (since this is + # expected behavior, not an error condition). + if is_repos_parent_directory(repo_path): + logger.debug( + "Git operation in repos parent directory", + operation=operation, + repo_path=repo_path, + container_id=container_id, + ) + return make_error( + f"Path '{repo_path}' is a directory containing repositories, not a git repository. " + "Run git commands from within a specific repository directory.", + status_code=400, + details={ + "hint": "This directory contains repositories but is not itself a git repository.", + "repo_path": repo_path, + }, + ) + + # Validate operation is in allowlist + if operation not in GIT_ALLOWED_COMMANDS: + _b().audit_log( + "git_execute_blocked", + operation, + success=False, + details={ + "repo_path": repo_path, + "git_args": args, + "container_id": container_id, + "reason": "Operation not allowed", + }, + ) + return make_error( + f"Operation '{operation}' not allowed. " + f"Allowed: {', '.join(sorted(GIT_ALLOWED_COMMANDS.keys()))}", + status_code=403, + ) + + # Network operations should use dedicated endpoints + if operation in ("push", "fetch", "ls-remote"): + return make_error( + f"Use dedicated endpoint for {operation}: /api/v1/git/{operation}", + status_code=400, + ) + + # Validate args against allowlist + args_valid, args_error, validated_args = validate_git_args(operation, args) + if not args_valid: + _b().audit_log( + "git_execute_blocked", + operation, + success=False, + details={ + "repo_path": repo_path, + "git_args": args, + "container_id": container_id, + "reason": args_error, + }, + ) + return make_error(args_error, status_code=400) + + # SECURITY: Scope `git update-ref` to the agent's own assigned branch. + # update-ref is the supported recovery primitive when an agent ends up on + # detached HEAD with a useful commit (see issue #2162). To keep the blast + # radius tight, the gateway rejects any update-ref that is not of the form + # `update-ref []` and force-prepends + # `--no-deref` below so symref-following semantics never apply. + if operation == "update-ref": + session = getattr(g, "session", None) + assigned = getattr(session, "assigned_branch", None) if session else None + positional = [a for a in validated_args if not a.startswith("-")] + denial_reason: str | None = None + if not isinstance(assigned, str) or not assigned: + denial_reason = ( + "git update-ref is only allowed in pipeline sessions with an assigned branch." + ) + elif len(positional) < 2 or len(positional) > 3: + denial_reason = ( + "git update-ref must be of the form `git update-ref []`." + ) + else: + expected_ref = f"refs/heads/{assigned}" + if positional[0] != expected_ref: + denial_reason = ( + f"git update-ref target '{positional[0]}' is not allowed. " + f"Only '{expected_ref}' (your assigned branch) may be updated. " + f"If you are trying to manually retarget your branch to drop " + f"pulled upstream commits and recover from a " + f"'restricted_path_modified' push 403, that is no longer " + f"necessary (#2489) — pulled commits authored by other roles " + f"are exempt from your role allowlist; retry the push as-is." + ) + if denial_reason is not None: + _b().audit_log( + "git_execute_blocked", + operation, + success=False, + details={ + "repo_path": repo_path, + "git_args": validated_args, + "container_id": container_id, + "assigned_branch": assigned, + "reason": denial_reason, + }, + ) + return make_error(denial_reason, status_code=403) + + # SECURITY: Scope `git symbolic-ref HEAD ` to the agent's own + # assigned or local per-role branch. symbolic-ref is the canonical + # reattach primitive when a worktree ends up on detached HEAD (e.g. + # post-rebase, see issue #2200). Restricted to the two-positional + # form `symbolic-ref HEAD ` — read forms (one-arg) and the + # delete form (`-d`) are rejected because they do not participate + # in the recovery flow. + if operation == "symbolic-ref": + session = getattr(g, "session", None) + assigned = getattr(session, "assigned_branch", None) if session else None + positional = [a for a in validated_args if not a.startswith("-")] + denial_reason = None + if not isinstance(assigned, str) or not assigned: + denial_reason = ( + "git symbolic-ref is only allowed in pipeline sessions with an assigned branch." + ) + elif len(positional) != 2: + denial_reason = "git symbolic-ref must be of the form `git symbolic-ref HEAD `." + elif positional[0] != "HEAD": + denial_reason = ( + f"git symbolic-ref source '{positional[0]}' is not allowed. " + f"Only HEAD may be retargeted." + ) + else: + allowed_refs = {f"refs/heads/{assigned}"} + # Defense in depth: scope the per-role local work branch from + # ``session.container_id`` (canonical, set by the orchestrator at + # session registration), not ``data.get("container_id")`` which + # is agent-supplied. Mirrors the ``update-ref`` guard above which + # also ignores the request-body container_id. + session_container_id = getattr(session, "container_id", None) + if isinstance(session_container_id, str) and session_container_id: + # Per-role local work branch (`egg/{container_id}/work`) + # — see worktree_manager._create_or_reuse_worktree. + allowed_refs.add(f"refs/heads/egg/{session_container_id}/work") + if positional[1] not in allowed_refs: + denial_reason = ( + f"git symbolic-ref target '{positional[1]}' is not allowed. " + f"Allowed targets: {sorted(allowed_refs)}." + ) + if denial_reason is not None: + _b().audit_log( + "git_execute_blocked", + operation, + success=False, + details={ + "repo_path": repo_path, + "git_args": validated_args, + "container_id": container_id, + "assigned_branch": assigned, + "reason": denial_reason, + }, + ) + return make_error(denial_reason, status_code=403) + + # SECURITY: Block agent-initiated ``git rebase`` against the base + # branch from pipeline sessions (#2224, follow-up to #2222). The + # pipeline branch is rebased onto the base branch only via the + # orchestrator's controlled rebase in + # ``orchestrator/routes/pipelines.py::_rebase_pipeline_branch_onto_base`` + # — which itself uses the *bare* form ``git rebase origin/`` + # but is safe because steps 1–5 of the helper enforce ancestry + # preconditions and reset HEAD to the pipeline-branch tip *before* + # the rebase replays. Crucially, that helper runs as a subprocess + # on the orchestrator-side worktree and does *not* route through + # this endpoint, so this guard does not interfere with it. An + # agent reaching for ``git rebase origin/main`` (intentionally or + # via a "resolve conflicts" intuition) reproduces the contamination + # shape from #2222 even with the orchestrator-side fixes in place. + # + # The ``--onto X UP `` form is allowed when ``X`` (the + # *new* base) is *not* a protected ref — that shape is used by the + # stacked-PR healer in + # ``orchestrator/gateway_client.py::rebase_onto``, which always + # passes a slice/issue branch as ``new_base`` (never ``origin/main``; + # see ``stacked_pr_reconciler._resolve_extant_new_base``). Calls + # with ``--onto origin/main …`` are *blocked*: when ``X == UP == + # origin/main`` the operation reduces to bare ``git rebase + # origin/main`` and reproduces the contamination shape (the value + # of ``UP`` is irrelevant — the new HEAD is whatever ``X`` + # resolves to, with the upstream-to-HEAD commits replayed on top). + if operation == "rebase": + session = getattr(g, "session", None) + assigned = getattr(session, "assigned_branch", None) if session else None + if isinstance(assigned, str) and assigned: + # ``protected_refs`` lists every form an agent (or an + # innocent rename) could use to name the base branch. We + # normalise inputs by stripping ``refs/remotes/`` and + # ``refs/heads/`` prefixes before comparing so canonical + # full ref names hit the same guard. Pipelines whose base + # is not ``main`` are not currently in production + # (orchestrator's ``base_branch`` defaults to ``main``); if + # non-main bases ship, derive this set from the session's + # recorded base branch instead of hardcoding it. + protected_refs = { + "origin/main", + "main", + "origin/HEAD", + "FETCH_HEAD", + } + + def _normalise_ref(value: str) -> str: + # Strip ``refs/remotes/`` (canonical full remote-tracking + # ref) and ``refs/heads/`` (canonical local-branch ref) + # so e.g. ``refs/remotes/origin/main`` matches + # ``origin/main`` in ``protected_refs``. Other shapes + # (SHAs, ``origin/main~1``, ``origin/main^``) are caught + # by exact-match below or fall through — they are + # acknowledged in the docstring as residual gaps. + if value.startswith("refs/remotes/"): + return value[len("refs/remotes/") :] + if value.startswith("refs/heads/"): + return value[len("refs/heads/") :] + return value + + offender: str | None = None + + # Branch 1: ``--onto `` is present. Reject when + # the *new base* (the value of ``--onto``) is a protected + # ref, regardless of what the upstream positional is. This + # closes the ``--onto origin/main origin/main`` bypass: + # ``git rebase --onto X UP`` rebases HEAD onto X using UP as + # the upstream, so when X is the base branch the operation + # produces the same contamination shape as bare ``git + # rebase origin/main``. + # + # Collect *every* ``--onto`` occurrence rather than the + # first — git's ``OPT_STRING`` semantics make duplicate + # ``--onto`` flags overwrite, so the *last* value wins, and + # an adversarial ``--onto safe --onto origin/main`` would + # otherwise slip past a first-match check. Reject when any + # of the supplied values is a protected ref. Empty values + # (``--onto=`` with nothing after) are treated as "not + # provided" so the bare-form upstream check below still + # runs against the positional args. + onto_values: list[str] = [] + j = 0 + while j < len(validated_args): + arg = validated_args[j] + if arg.startswith("--onto="): + value = arg.split("=", 1)[1] + if value: + onto_values.append(value) + elif arg == "--onto" and j + 1 < len(validated_args): + value = validated_args[j + 1] + if value: + onto_values.append(value) + j += 1 + j += 1 + + if onto_values: + offender = next( + (v for v in onto_values if _normalise_ref(v) in protected_refs), + None, + ) + else: + # Branch 2: bare ``git rebase []`` + # form — first positional is the upstream. Reject when + # the upstream is a protected ref. + positional = [a for a in validated_args if not a.startswith("-")] + offender = next( + (p for p in positional if _normalise_ref(p) in protected_refs), + None, + ) + + if offender is not None: + denial_reason = ( + f"git rebase against '{offender}' is not allowed in " + f"pipeline sessions. The pipeline branch is rebased " + f"onto the base branch only via the orchestrator's " + f"controlled rebase (`_rebase_pipeline_branch_onto_base`), " + f"which runs as a subprocess that does not route through " + f"this endpoint; an agent-initiated `git rebase " + f"origin/main` (or `--onto origin/main …`) reproduces " + f"the contamination shape from #2222. If you need to " + f"bring in new commits from the base, ask the operator " + f"to resume the pipeline so the orchestrator-side " + f"rebase runs. If you were trying to drop pulled upstream " + f"commits to recover from a 'restricted_path_modified' " + f"push 403, that is no longer necessary (#2489) — pulled " + f"commits authored by other roles are exempt from your " + f"role allowlist; retry the push as-is." + ) + _b().audit_log( + "git_execute_blocked", + operation, + success=False, + details={ + "repo_path": repo_path, + "git_args": validated_args, + "container_id": container_id, + "assigned_branch": assigned, + "reason": denial_reason, + }, + ) + return make_error(denial_reason, status_code=403) + + # SECURITY: Block branch-switching for pipeline sessions. + # Pipeline containers are locked to their worktree branch to prevent + # cross-contamination between pipeline tasks. + if is_branch_switch(operation, validated_args): + session = getattr(g, "session", None) + assigned = getattr(session, "assigned_branch", None) if session else None + if isinstance(assigned, str) and assigned: + _b().audit_log( + "git_execute_blocked", + operation, + success=False, + details={ + "repo_path": repo_path, + "git_args": validated_args, + "container_id": container_id, + "assigned_branch": assigned, + "reason": "Branch switching blocked in pipeline session", + }, + ) + return make_error( + f"Branch switching is not allowed in pipeline sessions. " + f"You are locked to branch '{assigned}'. " + f"Use 'git checkout [] -- ' to restore files instead " + f"(e.g. 'git checkout HEAD -- ' or 'git checkout -- '). " + f"If you are recovering from a 'restricted_path_modified' push 403, " + f"note that pulled commits authored by other roles are exempt from " + f"your role allowlist (#2489) — only your own commits' paths trigger " + f"the rejection, so retry the push first; if it still rejects, drop " + f"the disallowed paths from your own commits and re-propose with " + f"--pre-merge-condition (#1998 conditional ACK).", + status_code=403, + ) + + # Map container path to worktree path if container_id is provided + exec_path = _b().map_container_path_to_worktree(repo_path, container_id, operation) + if exec_path is None: + return make_worktree_not_found_error(container_id) + is_worktree = exec_path != repo_path + + # SECURITY: Block off-lineage `git reset` in pipeline sessions. + # `git reset ` (any mode) moves HEAD; if is not an ancestor of + # HEAD on the assigned branch, the agent's commits are silently dropped + # from the working tree — the same effect as a branch switch. The + # checkout/switch lock at :1924 does not catch this (see issue #2089). + if operation == "reset": + session = getattr(g, "session", None) + assigned = getattr(session, "assigned_branch", None) if session else None + if isinstance(assigned, str) and assigned: + target_ref = extract_reset_target_ref(validated_args) + if target_ref is not None: + ancestor_stderr: str | None = None + try: + ancestor_check = subprocess.run( + git_cmd("merge-base", "--is-ancestor", target_ref, "HEAD"), + cwd=exec_path, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + is_ancestor = ancestor_check.returncode == 0 + if not is_ancestor and ancestor_check.stderr: + ancestor_stderr = ancestor_check.stderr.strip() or None + except (OSError, subprocess.TimeoutExpired) as exc: + # Fail closed — if we cannot verify safety, treat as off-lineage. + is_ancestor = False + ancestor_stderr = str(exc) + if not is_ancestor: + audit_details = { + "repo_path": repo_path, + "git_args": validated_args, + "container_id": container_id, + "assigned_branch": assigned, + "target_ref": target_ref, + "reason": "Off-lineage reset blocked in pipeline session", + } + if ancestor_stderr: + audit_details["merge_base_stderr"] = ancestor_stderr + _b().audit_log( + "git_execute_blocked", + operation, + success=False, + details=audit_details, + ) + return make_error( + f"Off-lineage 'git reset' is not allowed in pipeline sessions. " + f"Target ref '{target_ref}' is not an ancestor of HEAD on your " + f"assigned branch '{assigned}'. To incorporate new commits from the " + f"remote, use 'git rebase origin/{assigned}' instead. " + f"If you are trying to drop pulled upstream commits to recover " + f"from a 'restricted_path_modified' push 403, that is no longer " + f"necessary (#2489) — pulled commits authored by other roles are " + f"exempt from your role allowlist; retry the push as-is.", + status_code=403, + ) + + # SECURITY: Enforce branch isolation in pipeline worktree sessions. + # Pipeline agents in worktrees must stay on their assigned branch. + # Interactive sessions are unrestricted even if they use worktrees. + # We detect pipeline sessions by the presence of pipeline_id on the + # session, rather than checking session_mode. + # See issue #773. + session = getattr(g, "session", None) + is_pipeline = session is not None and getattr(session, "pipeline_id", None) is not None + if is_pipeline and is_worktree and is_branch_switching_operation(operation, validated_args): + assert session is not None # guaranteed by is_pipeline check above + _b().audit_log( + "git_execute_blocked", + operation, + success=False, + details={ + "repo_path": repo_path, + "git_args": args, + "container_id": container_id, + "pipeline_id": session.pipeline_id, + "session_mode": getattr(g, "session_mode", None), + "reason": "Branch switching blocked in pipeline worktree session", + }, + ) + return make_error( + "Branch switching is not allowed in pipeline worktree sessions. " + "You are locked to your assigned branch. " + "Use 'git restore' for file operations instead of 'git checkout'. " + "If you are recovering from a 'restricted_path_modified' push 403, " + "note that pulled commits authored by other roles are exempt from " + "your role allowlist (#2489) — retry the push as-is; if it still " + "rejects, drop the disallowed paths from your own commits and " + "re-propose with --pre-merge-condition (#1998 conditional ACK).", + status_code=403, + ) + + # SECURITY: Validate staged files at commit time for pipeline sessions. + # This is an early-catch complement to push-time validation — prevents the + # agent from building up invalid commits that would only be rejected at push. + if operation == "commit": + session = getattr(g, "session", None) + session_phase = getattr(g, "session_phase", None) if session else None + if session_phase: + import subprocess as _sp + + try: + staged_result = _sp.run( + git_cmd("diff", "--cached", "--name-only"), + cwd=exec_path, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if staged_result.returncode == 0: + staged_files = [ + f.strip() for f in staged_result.stdout.strip().split("\n") if f.strip() + ] + if staged_files: + phase_result = _b().check_phase_file_restrictions( + session_phase, staged_files + ) + if not phase_result.allowed: + _b().audit_log( + "git_execute_blocked", + operation, + success=False, + details={ + "repo_path": repo_path, + "git_args": validated_args, + "container_id": container_id, + "phase": session_phase, + "blocked_files": phase_result.blocked_files, + "reason": "Staged files violate phase restrictions", + }, + ) + return make_error( + f"Commit blocked: {phase_result.message}. " + f"Unstage the blocked files with 'git reset HEAD '.", + status_code=403, + ) + except Exception: + # Fail open for commit-time check — push-time check is the + # authoritative gate and will catch any violations. + logger.warning( + "Staged-file check skipped due to error", + operation=operation, + container_id=container_id, + ) + + # SECURITY: Belt-and-suspenders hook prevention for operations that support it. + # The primary protection is core.hooksPath=/dev/null in git_cmd() which disables + # ALL hooks globally. However, we also add --no-verify for operations that + # support it as defense-in-depth. See issue #58. + # + # Operations that support --no-verify: + # - commit: pre-commit, prepare-commit-msg, commit-msg, post-commit + # - merge: pre-merge-commit, prepare-commit-msg, commit-msg, post-merge + # - am: pre-applypatch, applypatch-msg, post-applypatch + # + # Note: cherry-pick is NOT included here. While git 2.36+ added --no-verify + # for cherry-pick, older versions (including 2.34) reject it with a usage error. + # The primary protection (core.hooksPath=/dev/null) already covers cherry-pick. + # See issue #118. + if operation in ("commit", "merge", "am"): + validated_args = ["--no-verify", *validated_args] + + # SECURITY: Force-prepend `--no-deref` for `update-ref` (#2162). Without it, + # update-ref follows symref targets — the underlying ref is updated, not + # `refs/heads/`. In practice agent branches are never + # symrefs, but the gateway is a defense-in-depth boundary and the recovery + # flow never wants symref-following semantics. + if operation == "update-ref": + validated_args = ["--no-deref", *validated_args] + + # Build command + cmd = git_cmd(operation, *validated_args) + + # Set GIT_EDITOR=true so operations that need an editor (e.g., rebase + # --continue after conflict resolution) succeed without a terminal. + # `true` accepts the default commit message, which is the expected + # behavior for an agent that always provides messages via -m. + env = os.environ.copy() + env["GIT_EDITOR"] = "true" + + # Commit-authorship observer (#1882): snapshot HEAD before the git + # subcommand so we can compute which commits (if any) it created + # and register them with the orchestrator's authorship registry. + # Only agent sessions participate; internal gateway ops skip. + _observer_role: str | None = None + _observer_pipeline_id: str | None = None + _observer_repo: str | None = None + _observer_branch: str | None = None + _observer_before_head: str | None = None + _observer_armed: bool = False + _session_for_observer = getattr(g, "session", None) + if _session_for_observer is not None: + _observer_role = getattr(_session_for_observer, "agent_role", None) + _observer_pipeline_id = getattr(_session_for_observer, "pipeline_id", None) + _observer_repo = getattr(_session_for_observer, "repo", None) + _observer_branch = getattr(_session_for_observer, "assigned_branch", None) or getattr( + _session_for_observer, "branch", None + ) + # Intentionally exhaustive list of commit-creating operations. + # ``stash`` and ``pull`` can also create commit objects, but agents + # do not use them — all pushes go through the gateway's push handler + # which resolves attribution independently. Extend this list if + # agent workflows ever include stash or pull. + if _observer_role and operation in ( + "commit", + "merge", + "cherry-pick", + "revert", + "rebase", + "am", + ): + _observer_armed = True + _capture_head = _b()._lookup_commit_observer_fn("capture_head") + if _capture_head is not None: + try: + _observer_before_head = _capture_head(exec_path) + except Exception: # pragma: no cover - defensive + # before_head stays None; observe handles the + # unborn-branch case via its [after_head] fallback. + _observer_before_head = None + + try: + result = subprocess.run( + cmd, + cwd=exec_path, + capture_output=True, + text=True, + timeout=60, + check=False, + env=env, + ) + + if result.returncode == 0: + # Fire the observer only on the narrow list of ref-mutating + # operations that armed the observer above. For all other + # operations (status, checkout, restore, ...) we skip the + # post-op rev-parse entirely so callers' subprocess + # mocking isn't perturbed. Note: _observer_before_head + # may be None on unborn branches — observe() handles that + # via its [after_head] fallback. + if _observer_role and _observer_armed: + try: + _observe_after = _b()._lookup_commit_observer_fn("observe_after_git_execute") + if _observe_after is not None: + _observe_after( + exec_path, + before_head=_observer_before_head, + branch=_observer_branch, + session_role=_observer_role, + pipeline_id=_observer_pipeline_id, + repo=_observer_repo, + ) + except Exception: + # Observer is best-effort — never block the git + # response on a registry failure. + logger.debug( + "commit_observer_swallowed", + exc_info=True, + ) + _b().audit_log( + "git_execute_success", + operation, + success=True, + details={ + "repo_path": repo_path, + "git_args": validated_args, + "container_id": container_id, + }, + ) + + # Detached-HEAD recovery hint (#2162). After a successful commit + # in a pipeline session, surface a clear hint if HEAD is detached + # so the agent doesn't spend minutes guessing at policy bypasses + # to update its work branch ref. + hint = _detached_head_hint(operation, exec_path, repo_path, container_id) + stderr_out = (result.stderr or "") + hint if hint else result.stderr + + return make_success( + f"git {operation} successful", + { + "stdout": result.stdout, + "stderr": stderr_out, + "returncode": result.returncode, + }, + ) + else: + # Check if this is an expected failure (e.g., repo detection queries) + # These happen when tools check if a directory is a git repo + is_expected_failure = result.stderr and ( + "not a git repository" in result.stderr + or "not inside a git repository" in result.stderr + ) + + if is_expected_failure: + # Log at debug level for expected failures - these are typically + # from tools probing to detect if they're in a git repo + logger.debug( + "Git operation failed (expected - not a git repository)", + operation=operation, + repo_path=repo_path, + container_id=container_id, + ) + else: + # Log at warning level for unexpected failures + _b().audit_log( + "git_execute_failed", + operation, + success=False, + details={ + "repo_path": repo_path, + "git_args": validated_args, + "returncode": result.returncode, + "container_id": container_id, + "stderr": result.stderr[:500] if result.stderr else None, + }, + ) + + # Surface the detached-HEAD recovery hint on failure too. Common + # cases (rebase --onto mid-conflict, missing --allow-empty, index + # locks) produce a *failed* commit while detached, and the hint is + # exactly what cuts that confusion short. + failure_hint = _detached_head_hint(operation, exec_path, repo_path, container_id) + failure_stderr = (result.stderr or "") + failure_hint if failure_hint else result.stderr + return make_error( + f"git {operation} failed", + status_code=500, + details={ + "stdout": result.stdout, + "stderr": failure_stderr, + "returncode": result.returncode, + }, + ) + + except subprocess.TimeoutExpired: + return make_error(f"git {operation} timed out", status_code=504) + except Exception as e: + return make_error(f"git {operation} failed: {e}", status_code=500) diff --git a/gateway/gateway/_git_ops.py b/gateway/gateway/_git_ops.py new file mode 100644 index 0000000000..b591c843fa --- /dev/null +++ b/gateway/gateway/_git_ops.py @@ -0,0 +1,1357 @@ +"""Gateway git_ops cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from typing import Any + +from egg_restrictions.hints import derive_hint as _derive_push_denied_hint +from flask import Response, g, request + +try: + from ..git_client import ( + cleanup_credential_helper, + create_credential_helper, + git_cmd, + validate_git_args, + ) + from ..phase_filter import ( + check_anchor_write_permission, + ) + from ..policy import ( + extract_branch_from_refspec, + extract_repo_from_remote, + ) + from ..repo_parser import ( + parse_owner_repo, + ) +except ImportError: # flat/container import mode + from git_client import ( # type: ignore[no-redef, import-untyped] + cleanup_credential_helper, + create_credential_helper, + git_cmd, + validate_git_args, + ) + from phase_filter import ( # type: ignore[no-redef, import-untyped] + check_anchor_write_permission, + ) + from policy import ( # type: ignore[no-redef, import-untyped] + extract_branch_from_refspec, + extract_repo_from_remote, + ) + from repo_parser import ( # type: ignore[no-redef, import-untyped] + parse_owner_repo, + ) + +from ._helpers import make_error, make_success, make_worktree_not_found_error + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + + +def _detached_head_hint( + operation: str, + exec_path: str, + repo_path: str, + container_id: str | None, +) -> str: + """Return a recovery hint string when a `commit` lands on detached HEAD. + + Used by the git-execute handler to surface the exact ``update-ref`` + invocation an agent needs to set its work branch to the new commit + (issue #2162). The empty string means "no hint" — caller appends as-is. + + The trigger is intentionally narrow: + + * Only ``operation == "commit"`` and only when the session has an + ``assigned_branch`` — we do not want to noise non-pipeline sessions. + * ``git symbolic-ref --quiet HEAD`` must return exactly 1 with empty + stdout AND empty stderr. Returncode 128 (corrupt repo, .git missing, + "fatal: ...") and any non-empty stderr are treated as ambiguous and + yield no hint — telling the agent to run ``update-ref`` against a + broken repository would be misleading. + """ + if operation != "commit": + return "" + session = getattr(g, "session", None) + assigned = getattr(session, "assigned_branch", None) if session else None + if not isinstance(assigned, str) or not assigned: + return "" + try: + head_check = subprocess.run( + git_cmd("symbolic-ref", "--quiet", "HEAD"), + cwd=exec_path, + capture_output=True, + text=True, + timeout=2, + check=False, + ) + except OSError, subprocess.TimeoutExpired: + return "" + # Tight check: returncode 1 with no stdout and no stderr is unambiguously + # detached HEAD. Anything else (corrupt repo, missing .git, EAGAIN) gets + # no hint. + if head_check.returncode != 1: + return "" + if head_check.stdout.strip(): + return "" + if head_check.stderr.strip(): + # Symbolic-ref returncode==1 with empty stdout but non-empty stderr is + # ambiguous (e.g. future git versions writing config-deprecation + # warnings). Log at debug so a missing hint is debuggable rather than + # silent, and bail out — telling the agent to run update-ref against + # an unclear HEAD state would be misleading. + logger.debug( + "detached_head_hint_suppressed_stderr", + repo_path=repo_path, + container_id=container_id, + assigned_branch=assigned, + stderr=head_check.stderr.strip()[:200], + ) + return "" + logger.info( + "detached_head_commit_hint", + repo_path=repo_path, + container_id=container_id, + assigned_branch=assigned, + ) + return ( + f"\n[gateway] HEAD is detached. Your commit is not on " + f"branch '{assigned}'. To set the branch to this commit, run:\n" + f" git update-ref refs/heads/{assigned} HEAD\n" + ) + + +_SLICE_INTEGRATION_BRANCH_RE = re.compile(r"^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\d+$") + + +def git_push() -> tuple[Response, int] | Response: + """ + Handle git push requests. + + Request body: + { + "repo_path": "/path/to/repo", + "remote": "origin", + "refspec": "branch-name", + "force": false, + "force_with_lease": false, # safer alternative to force + "commit_sha": "<40-hex>", # alternative to refspec; consensus pushes only + } + + ``force_with_lease`` (#2137 stacked-PR reconciler) is preferred over + ``force`` for non-fast-forward pushes. Both flags are mutually + exclusive — ``force_with_lease`` takes precedence if both are set. + + Policy: branch_ownership + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + repo_path = data.get("repo_path") + remote = data.get("remote", "origin") + refspec = data.get("refspec", "") + force = data.get("force", False) + force_with_lease = data.get("force_with_lease", False) + container_id = data.get("container_id") + commit_sha = data.get("commit_sha", "") + + if not repo_path: + return make_error("Missing repo_path") + + # Detached-HEAD-tolerant consensus push (#2200): when the agent's HEAD + # is detached (post-rebase or otherwise), the helper cannot read + # ``git branch --show-current`` and instead supplies ``commit_sha``. + # The gateway derives the refspec server-side from the session's + # assigned branch. This is strictly tighter than an agent-supplied + # refspec because the existing ``push_target_enforcement`` block + # below already requires ``branch == session.assigned_branch``. + if commit_sha and not refspec: + if not data.get("consensus_push"): + _b().audit_log( + "push_blocked", + "git_push", + success=False, + details={ + "repo_path": repo_path, + "reason": "commit_sha push requires consensus_push=true", + }, + ) + return make_error( + "commit_sha push requires consensus_push=true", + status_code=400, + ) + # Require a full SHA (40 = SHA-1, 64 = SHA-256). Abbreviated SHAs + # (7-39 chars) can resolve ambiguously on the gateway side; the + # helper always emits the full output of ``git rev-parse HEAD`` so + # there is no legitimate caller of the shorter range. The explicit + # ``isinstance`` guard turns a non-string payload into a clean 400 + # rather than a 500 from ``re.fullmatch``. + if not isinstance(commit_sha, str) or not re.fullmatch(r"[0-9a-f]{40,64}", commit_sha): + _b().audit_log( + "push_blocked", + "git_push", + success=False, + details={ + "repo_path": repo_path, + "reason": f"Invalid commit_sha {commit_sha!r}", + }, + ) + return make_error( + f"Invalid commit_sha {commit_sha!r}: must be 40-64 hex chars", + status_code=400, + ) + session = getattr(g, "session", None) + assigned = getattr(session, "assigned_branch", None) if session else None + if not isinstance(assigned, str) or not assigned: + _b().audit_log( + "push_blocked", + "git_push", + success=False, + details={ + "repo_path": repo_path, + "reason": "commit_sha push requires a pipeline session with assigned_branch", + }, + ) + return make_error( + "commit_sha push requires a pipeline session with an assigned branch", + status_code=400, + ) + refspec = f"{commit_sha}:refs/heads/{assigned}" + # Distinct audit event so post-incident review can distinguish a + # gateway-constructed refspec (commit_sha path) from an + # agent-supplied refspec; both flow through the same downstream + # ``push_*`` audit events and would otherwise be indistinguishable. + _b().audit_log( + "push_via_commit_sha", + "git_push", + success=True, + details={ + "repo_path": repo_path, + "commit_sha": commit_sha, + "assigned_branch": assigned, + "constructed_refspec": refspec, + }, + ) + + # Validate repo_path to prevent path traversal attacks + path_valid, path_error = _b().validate_repo_path(repo_path) + if not path_valid: + _b().audit_log( + "push_blocked", + "git_push", + success=False, + details={"repo_path": repo_path, "reason": path_error}, + ) + return make_error(path_error, status_code=403) + + # Map container path to worktree path if container_id is provided + exec_path = _b().map_container_path_to_worktree(repo_path, container_id, "push") + if exec_path is None: + return make_worktree_not_found_error(container_id) + + # Get remote URL to determine repo + remote_url, url_error = _b().resolve_remote_url(remote, exec_path) + if url_error: + return make_error(url_error) + + # Extract repo from URL + repo = extract_repo_from_remote(remote_url) + if not repo: + return make_error(f"Could not parse repository from URL: {remote_url}") + + # Extract branch from refspec + branch = extract_branch_from_refspec(refspec) + if not branch: + # Try to get current branch + try: + result = subprocess.run( + git_cmd("branch", "--show-current"), + cwd=exec_path, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + branch = result.stdout.strip() + except Exception: + pass + + if not branch: + return make_error("Could not determine branch to push") + + # Determine auth mode for this repo + auth_mode = _b().get_auth_mode(repo) + + # Check Private Repo Mode policy (if enabled) + # Get session mode from request context (set by @require_session_auth decorator) + session_mode = getattr(g, "session_mode", None) + session_phase = getattr(g, "session_phase", None) + + # Orchestrator-authenticated push (launcher secret). The orchestrator + # has a different trust boundary than sandboxed agents — its pushes are + # programmatic (contract init, state-sync, completion) and bypass the + # session-derived enforcement (pipeline-push block, push-target check, + # role/phase file restrictions) that exists to sandbox agent commits. + # session_mode comes from the request body since there is no session. + is_orchestrator_push = getattr(g, "auth_actor", None) == "launcher" + if is_orchestrator_push: + mode_in = data.get("mode") + if mode_in is not None and mode_in not in ("public", "private"): + _b().audit_log( + "push_blocked", + "git_push", + success=False, + details={ + "repo_path": repo_path, + "reason": f"Invalid mode {mode_in!r} on launcher-auth push", + }, + ) + return make_error( + f"Invalid mode {mode_in!r}: must be 'public' or 'private'", + status_code=400, + ) + session_mode = mode_in or session_mode + _b().audit_log( + "push_orchestrator_authenticated", + "git_push", + success=True, + details={ + "repo_path": repo_path, + "remote": remote, + "refspec": refspec, + "reason": "Push authenticated with launcher secret — orchestrator-trusted", + }, + ) + + # Infrastructure branch bypass: pushes to infrastructure branches always succeed + # regardless of session mode or phase (pipeline state can be written at any time). + from egg_config.constants import PIPELINE_STATE_BRANCH + + INFRASTRUCTURE_BRANCHES = {PIPELINE_STATE_BRANCH} + is_infrastructure_push = branch in INFRASTRUCTURE_BRANCHES + + # Slice integration-branch creation (#2368): the orchestrator pre-creates + # ``egg//(slice|phase)-N`` on origin from the parent branch via a + # synthetic, launcher-authenticated session before any agent runs. That + # push is orchestrator infrastructure — not an agent BRC propose — so it + # must bypass the pipeline-session push block introduced in #2028. The + # ``synthetic=True`` flag can only be set by the launcher (the + # ``/api/v1/sessions/create`` endpoint is gated by ``require_launcher_auth``), + # so a sandboxed agent's session token cannot reach this branch. + # + # The legacy ``egg//context`` context-branch exemption (#2548) was + # removed in #2777 (cq-2 / cq-4): the dedicated context branch is gone + # and the context PR now opens on ``egg//work → main`` directly, + # which is already covered by the pipeline-session push-allow list. + is_slice_integration_push = False + if not is_infrastructure_push and _SLICE_INTEGRATION_BRANCH_RE.match(branch): + # ``Session.synthetic`` is a ``bool`` (default ``False``); only an + # orchestrator-issued session can carry ``synthetic=True`` because + # ``/api/v1/sessions/create`` is gated on the launcher secret. Use + # an identity check rather than a truthiness test so a future + # surface that ever stores something other than ``True`` (and any + # MagicMock fake whose default attr is truthy) cannot accidentally + # opt into the exemption. + if hasattr(g, "session") and getattr(g.session, "synthetic", False) is True: + is_slice_integration_push = True + is_infrastructure_push = True + _b().audit_log( + "push_slice_integration_exempt", + "git_push", + success=True, + details={ + "repo_path": repo_path, + "remote": remote, + "refspec": refspec, + "branch": branch, + "reason": ( + "Synthetic-session slice integration branch push — " + "orchestrator infrastructure (#2368)" + ), + }, + ) + + repo_info = parse_owner_repo(repo) + if repo_info: + # Infrastructure operations — always accessible regardless of + # session mode. This covers infrastructure branch pushes + # (pipeline state) and synthetic slice-integration pushes. + if is_infrastructure_push: + if is_slice_integration_push: + exempt_type = "slice_integration_branch" + else: + exempt_type = "infrastructure_branch" + # A successful slice-integration push intentionally emits BOTH + # ``push_slice_integration_exempt`` (above, the orchestrator- + # specific event) AND ``push_infrastructure_exempt`` with + # ``exempt_type="slice_integration_branch"`` (here, the generic + # exemption event). Operators grepping ``push_infrastructure_exempt`` + # for "infra pushes" should filter out the slice variant via + # ``exempt_type``; the dual emission is intentional so the + # orchestrator-specific path is also visible to operators + # filtering on the slice-integration event name (#2370 review). + _b().audit_log( + "push_infrastructure_exempt", + "git_push", + success=True, + details={ + "repo": repo, + "branch": branch, + "reason": "Infrastructure operation exempt from private mode policy", + "exempt_type": exempt_type, + }, + ) + else: + priv_result = _b().check_private_repo_access( + operation="push", + owner=repo_info.owner, + repo=repo_info.repo, + for_write=True, + session_mode=session_mode, + ) + if not priv_result.allowed: + _b().audit_log( + "push_denied_private_mode", + "git_push", + success=False, + details={ + "repo": repo, + "branch": branch, + "reason": priv_result.reason, + "visibility": priv_result.visibility, + "auth_mode": auth_mode, + }, + ) + return make_error( + priv_result.reason, + status_code=403, + details=priv_result.to_dict(), + ) + + # SECURITY: Pipeline push enforcement. + # All SDLC producer phases (refine/plan/implement) are BRC phases, so every + # pipeline-session push must route through mcp__brc__propose (which sets the + # consensus_push marker). A direct git push from a pipeline session — whether + # bare, mis-targeted, or correctly-targeted — is rejected with a single + # unambiguous error pointing at the right tool, instead of the three-layer + # error cascade that previously sent agents refspec-hunting (#2028). + # Infrastructure pushes (pipeline-state branch, etc.) are exempt. + if not is_infrastructure_push: + # Killswitch: PIPELINE_PUSH_ENFORCEMENT=false (legacy alias: + # CONCURRENT_PUSH_ENFORCEMENT=false) disables the block. + enforcement_env = os.environ.get( + "PIPELINE_PUSH_ENFORCEMENT", + os.environ.get("CONCURRENT_PUSH_ENFORCEMENT", "true"), + ) + pipeline_push_enforcement = enforcement_env.lower() not in ("false", "0", "no") + if pipeline_push_enforcement: + session_pipeline_id = None + if hasattr(g, "session") and g.session: + session_pipeline_id = getattr(g.session, "pipeline_id", None) + if isinstance(session_pipeline_id, str) and session_pipeline_id: + if not data.get("consensus_push"): + _b().audit_log( + "push_denied_pipeline_session", + "git_push", + success=False, + details={ + "repo": repo, + "branch": branch, + "pipeline_id": session_pipeline_id, + "reason": "Direct push blocked for pipeline session", + }, + ) + return make_error( + "Direct git push is blocked for pipeline sessions. " + "Publish your artifact via the mcp__brc__propose tool " + "(which pushes to origin and sends CONSENSUS_PROPOSE " + "in one step). Fallback CLI: " + "`egg-orch consensus propose --push`.", + status_code=403, + details={ + "pipeline_id": session_pipeline_id, + "requirement": "consensus_push", + "recommended_tool": "mcp__brc__propose", + }, + ) + + # Push-target enforcement: a consensus_push request must still target the + # session's assigned branch. Defense-in-depth against a malformed propose + # call (consensus_push=true but wrong refspec). Non-pipeline sessions + # (e.g. user-mode pushes) are not subject to this check. + # Killswitch: PUSH_TARGET_ENFORCEMENT=false. + push_target_enforcement = os.environ.get("PUSH_TARGET_ENFORCEMENT", "true").lower() not in ( + "false", + "0", + "no", + ) + if push_target_enforcement and hasattr(g, "session") and g.session: + session_pipeline_id = getattr(g.session, "pipeline_id", None) + session_assigned_branch = getattr(g.session, "assigned_branch", None) + if isinstance(session_pipeline_id, str) and isinstance(session_assigned_branch, str): + if branch != session_assigned_branch: + _b().audit_log( + "push_denied_wrong_branch", + "git_push", + success=False, + details={ + "repo": repo, + "branch": branch, + "assigned_branch": session_assigned_branch, + "pipeline_id": session_pipeline_id, + }, + ) + return make_error( + f"Pipeline sessions must push to their assigned branch " + f"'{session_assigned_branch}'. Got '{branch}'. " + f"mcp__brc__propose handles branch targeting for you.", + status_code=403, + details={ + "assigned_branch": session_assigned_branch, + "attempted_branch": branch, + "pipeline_id": session_pipeline_id, + }, + ) + + # Check branch ownership policy (pass auth mode for relaxed policy in user mode) + policy = _b().get_policy_engine() + policy_result = policy.check_branch_ownership(repo, branch, auth_mode=auth_mode) + + if not policy_result.allowed: + _b().audit_log( + "push_denied", + "git_push", + success=False, + details={ + "repo": repo, + "branch": branch, + "reason": policy_result.reason, + "auth_mode": auth_mode, + }, + ) + return make_error( + f"Push denied: {policy_result.reason}", + status_code=403, + details=policy_result.details, + ) + + # SECURITY: Resolve the changed-file set + fail closed if we can't. + # The agent-role and phase-based restriction checks below both consume + # ``changed_files``; computing it once here keeps the security gates + # consistent and lets the fail-closed branch run even if neither + # session has a role (the phase check still runs in that case). + # + # Infrastructure pushes (pipeline-state and synthetic-session slice + # integration-branch creation pushes; see is_infrastructure_push above) + # are exempt for two distinct reasons: + # 1. ``egg/pipeline-state`` is an orphan/disjoint-history branch written + # by orchestrator infrastructure, not agent BRC pushes, so role-based + # file restrictions don't conceptually apply. + # 2. Synthetic-session slice integration-branch creation pushes (#2368) + # diff against `main` because the target ref doesn't exist yet, which + # would otherwise pull in every file modified on the parent branch's + # history (drafts, contracts, brc-history, ...) and falsely block a + # logical no-op branch-creation push (#2372). + # The downstream anchor/phase/agent-restriction checks already gate on + # `not is_infrastructure_push`; this gate makes the role check symmetric. + session_role = None + # Pipeline base branch (#3024): used as the preferred diff base for the + # new-branch fallback so a branch forked from a non-trunk base is not + # blamed for files it inherited unchanged from that base. + session_base_branch = None + changed_files = None # populated below; reused by attribution + phase checks + if hasattr(g, "session") and g.session: + session_role = getattr(g.session, "agent_role", None) + session_base_branch = getattr(g.session, "base_branch", None) + + if session_role and not is_infrastructure_push: + # Get the list of files being pushed for downstream attribution-aware + # role enforcement (the canonical agent-role check below) and the + # phase-restriction check further down. + changed_files, check_error = _b().get_changed_files_in_push( + exec_path, remote, branch, base_branch=session_base_branch + ) + + # SECURITY: Fail closed - if we can't determine changed files, block the push. + # This prevents bypass via git diff manipulation (timeout, corrupt refs, etc.) + if check_error: + _b().audit_log( + "push_denied_file_check_failed", + "git_push", + success=False, + details={ + "repo": repo, + "branch": branch, + "role": session_role, + "error": check_error, + }, + ) + return make_error( + f"Push denied: Could not verify file changes for security check: {check_error}", + status_code=500, + details={ + "role": session_role, + "error": check_error, + "hint": "This is a security precaution. Try again or contact support.", + }, + ) + + # Note: the legacy whole-push-diff role check that used to live here + # (``check_file_restrictions(session_role, changed_files)``) was + # removed in #2489. It treated every file in the diff range as the + # pushing role's responsibility, even files modified only by pulled + # commits authored by other roles, which trapped role-restricted + # producers whose branches inherited unrelated upstream commits + # (the role had no sanctioned recovery path). The attribution- + # aware block below partitions own-authored vs pulled files via the + # commit-authorship registry and is now the canonical agent-role + # restriction enforcer; it preserves fail-closed semantics when + # attribution is unavailable. + + # Agent-role file restrictions (#2039 restricted-path rejection). + # The gateway partitions the push range into own-authored vs + # pulled-from-other-role files via the commit-authorship registry, + # checks the pushing role's write permissions against only the + # own-authored set, and either pushes unchanged (all allowed) + # or rejects with 403 restricted_path_modified (any blocked). + # + # EGG_AGENT_RESTRICTIONS_ENFORCE=false short-circuits the filter + # (warn-only, same as the old 403 path). + auto_filter_response: dict[str, Any] | None = None + attributed_push: Any = None + if session_role and changed_files and not is_infrastructure_push: + enforce = os.environ.get("EGG_AGENT_RESTRICTIONS_ENFORCE", "true").lower() not in ( + "false", + "0", + "no", + ) + _ar_mod = sys.modules.get("agent_restrictions") or sys.modules.get( + "gateway.agent_restrictions" + ) + _partition_fn: Any = getattr(_ar_mod, "partition_files_by_role", None) if _ar_mod else None + if _partition_fn is None: + try: + from agent_restrictions import ( + partition_files_by_role as _imported_partition, + ) + + _partition_fn = _imported_partition + except ImportError: # pragma: no cover + from ..agent_restrictions import ( + partition_files_by_role as _imported_partition, + ) + + _partition_fn = _imported_partition + + _gc_mod = sys.modules.get("git_client") or sys.modules.get("gateway.git_client") + _get_attributed_fn: Any = ( + getattr(_gc_mod, "get_attributed_changed_files_in_push", None) if _gc_mod else None + ) + if _get_attributed_fn is None: + try: + from git_client import ( # type: ignore[no-redef, import-untyped] + get_attributed_changed_files_in_push as _imported_attr, + ) + + _get_attributed_fn = _imported_attr + except ImportError: # pragma: no cover + from ..git_client import ( + get_attributed_changed_files_in_push as _imported_attr, + ) + + _get_attributed_fn = _imported_attr + + # Resolve attribution for every commit in the push range. + try: + attributed_push = _get_attributed_fn( + exec_path, + remote, + branch, + session_role=session_role, + base_branch=session_base_branch, + ) + except Exception as exc: + logger.warning("attribution_lookup_exception", error=str(exc), exc_info=True) + # Fail-closed: an unexpected exception is treated as + # attribution-unavailable so the rewrite path never + # pushes unvetted files. + _apr_cls = getattr(_gc_mod, "AttributedPushRange", None) if _gc_mod else None + if _apr_cls is not None: + attributed_push = _apr_cls(error=f"Attribution lookup failed: {exc}") + else: + from types import SimpleNamespace + + attributed_push = SimpleNamespace( + error=f"Attribution lookup failed: {exc}", + commits=[], + files=[], + attribution={}, + ) + + # When the per-commit attribution can't be computed (e.g. the + # caller mocked only the legacy file-detection path, or git + # rev-list returned zero commits but there are staged-but-not- + # pushed changes we can't walk with commit-tree), we FAIL + # CLOSED. Treat every file in ``changed_files`` as own-authored + # and unregistered; if any file is blocked the push is rejected + # by the restricted-path arm below (#2039). + attribution_fallback = bool(attributed_push.error or not attributed_push.commits) + if attribution_fallback: + own_files: list[str] = list(dict.fromkeys(changed_files)) + pulled_files: list[str] = [] + unregistered_files: list[str] = list(own_files) + attributed_commits_list: list[str] = [] + else: + # Split files by author role (pushing role's own vs pulled). + own_files = [] + pulled_files = [] + unregistered_files = [] + for attr in attributed_push.files: + if attr.authored_by is None: + # Fail-closed: unregistered commits are treated as + # own-authored. + own_files.append(attr.path) + unregistered_files.append(attr.path) + elif attr.authored_by == session_role: + own_files.append(attr.path) + else: + pulled_files.append(attr.path) + own_files = list(dict.fromkeys(own_files)) + pulled_files = list(dict.fromkeys(pulled_files)) + attributed_commits_list = list(attributed_push.commits) + + # Build the pulled_commits list for the response + audit log. + pulled_commits_summary: list[dict[str, Any]] = [] + for sha in attributed_commits_list: + role_for_sha = attributed_push.attribution.get(sha) if attributed_push else None + if role_for_sha and role_for_sha != session_role: + pulled_commits_summary.append({"sha": sha, "author_role": role_for_sha}) + + allowed_own, blocked_own = _partition_fn(session_role, own_files, repo=repo) + + if unregistered_files and enforce: + _b().audit_log( + "push_authorship_unregistered_fallback", + "git_push", + success=True, + details={ + "repo": repo, + "branch": branch, + "role": session_role, + "unregistered_files": unregistered_files, + "blocked_paths": blocked_own, + "pulled_commits": pulled_commits_summary, + }, + ) + + if blocked_own and enforce: + # #2039: reject any push whose diff modifies a path the + # pushing role cannot write. The previous behavior — silent + # tree rewrite (mixed) or silent ``nothing_to_push=true`` + # (all-blocked) — produced destructive deletions on the + # shared branch and gave the agent no actionable signal. + # Reject loudly with a structured 403 that points at the + # supported recovery pattern (#1998 conditional ACK with + # ``--pre-merge-condition``). + sorted_blocked = sorted(set(blocked_own)) + _b().audit_log( + "push_denied_restricted_path_modified", + "git_push", + success=False, + details={ + "repo": repo, + "branch": branch, + "role": session_role, + "blocked_paths": sorted_blocked, + "pulled_commits": pulled_commits_summary, + "attribution_fallback": attribution_fallback, + }, + ) + recommended_action = ( + "Drop the edits to the listed paths and re-propose with " + "--pre-merge-condition flagging a manual change for the " + "human reviewer (see issue #1998 for the conditional-ACK " + "pattern)." + ) + details: dict[str, Any] = { + "error": "restricted_path_modified", + "role": session_role, + "blocked_paths": sorted_blocked, + "recommended_action": recommended_action, + "doc_ref": "#1998", + "pulled_commits": pulled_commits_summary, + "attribution_fallback": attribution_fallback, + } + # #2355 hint catalogue: surface category-specific guidance + # (e.g. "Use egg-contract CLI commands…" for contract paths, + # "Documentation changes belong to the documenter role." for + # docs/) alongside the generic conditional-ACK pointer. The + # legacy whole-push-diff check used to do this; restoring it + # here keeps the response shape consistent with the anchor- + # write 403 below. + hint = _derive_push_denied_hint(sorted_blocked) + if hint is not None: + details["hint"] = hint + return make_error( + ( + f"Push denied: role '{session_role}' cannot modify restricted " + f"paths: {', '.join(sorted_blocked)}. " + f"{recommended_action}" + ), + status_code=403, + details=details, + ) + elif blocked_own and not enforce: + # Warn-only mode: log but let the plain push proceed. + # Explicitly flag ``enforce=false`` so operators scanning + # audit logs during a kill-switch window can distinguish + # this from the enforced paths. + logger.warning( + "Agent-role file restriction would block push (warn-only)", + event_type="agent_role_restriction_warning", + repo=repo, + branch=branch, + role=session_role, + blocked_files=blocked_own, + enforce=False, + ) + # Observability parity (#1882 TASK-3-3): even the warn- + # only passthrough must surface pulled_commits and the + # filtered=false flag in the success response so + # downstream tooling sees a consistent schema. + auto_filter_response = { + "filtered": False, + "excluded_files": [], + "pushed_files": own_files + pulled_files, + "pulled_commits": pulled_commits_summary, + } + else: + # All own-files are allowed. No rewrite needed. We still + # stash the pulled_commits summary so the success path can + # surface it in the response for observability. + auto_filter_response = { + "filtered": False, + "excluded_files": [], + "pushed_files": own_files + pulled_files, + "pulled_commits": pulled_commits_summary, + } + + # SECURITY: Check anchor file write scoping. + # Agents can only write to their own anchor file (.egg-state/agent-anchors/.json). + # The agent_anchor_id is set via the AGENT_ANCHOR_ID env var in the container. + if changed_files and not is_infrastructure_push: + session_anchor_id = None + if hasattr(g, "session") and g.session: + session_anchor_id = getattr(g.session, "agent_anchor_id", None) + for changed_file in changed_files: + anchor_result = check_anchor_write_permission(changed_file, session_anchor_id) + if not anchor_result.allowed: + _b().audit_log( + "push_denied_anchor_write", + "git_push", + success=False, + details={ + "repo": repo, + "branch": branch, + "agent_anchor_id": session_anchor_id, + "blocked_files": anchor_result.blocked_files, + "blocked_reason": anchor_result.blocked_reason, + }, + ) + anchor_details: dict[str, Any] = { + "agent_anchor_id": session_anchor_id, + "blocked_files": anchor_result.blocked_files, + "blocked_reason": anchor_result.blocked_reason, + } + # Anchor-write violations bypass the role-level partition (the + # coder blocklist exempts .egg-state/agent-anchors/), so they + # need their own derive_hint call to deliver the + # orchestrator-API guidance from BLOCKED_HINTS. See #2355. + anchor_hint = _derive_push_denied_hint(anchor_result.blocked_files) + if anchor_hint is not None: + anchor_details["hint"] = anchor_hint + return make_error( + f"Push denied: {anchor_result.message}", + status_code=403, + details=anchor_details, + ) + + # SECURITY: Check phase-based file restrictions for local mode sessions. + # This replaces the blanket local-mode push block with granular phase-based + # restrictions. Each phase has specific allowed/blocked file patterns: + # - refine/plan: Can only push .egg-state/ files (contracts, drafts, checkpoints) + # - implement: Can push code but not .egg-state/ (except checkpoints) + # - pr: Can push everything + if session_phase and not is_infrastructure_push: + # Get the list of files being pushed (reuse if already fetched for role check) + if changed_files is None: + changed_files, check_error = _b().get_changed_files_in_push( + exec_path, remote, branch, base_branch=session_base_branch + ) + if check_error: + _b().audit_log( + "push_denied_file_check_failed", + "git_push", + success=False, + details={ + "repo": repo, + "branch": branch, + "phase": session_phase, + "error": check_error, + }, + ) + return make_error( + f"Push denied: Could not verify file changes for phase check: {check_error}", + status_code=500, + details={ + "phase": session_phase, + "error": check_error, + "hint": "This is a security precaution. Try again or contact support.", + }, + ) + + # Check phase-based file restrictions + phase_result = _b().check_phase_file_restrictions(session_phase, changed_files) + if not phase_result.allowed: + _b().audit_log( + "push_denied_phase_restrictions", + "git_push", + success=False, + details={ + "repo": repo, + "branch": branch, + "phase": session_phase, + "blocked_files": phase_result.blocked_files, + "blocked_reason": phase_result.blocked_reason, + }, + ) + has_non_state_files = any( + not f.startswith(".egg-state/") for f in phase_result.blocked_files + ) + # Pipeline sessions get a pipeline-specific hint pointing to + # egg-orch; non-pipeline sessions see the original generic hint. + session_pipeline_id = None + if hasattr(g, "session") and g.session: + session_pipeline_id = getattr(g.session, "pipeline_id", None) + + if has_non_state_files and isinstance(session_pipeline_id, str): + hint = ( + "Push contains files from prior pipeline phases that this phase " + "cannot modify. This indicates the worktree was not properly synced. " + "Signal an error with `egg-orch signal error --error 'Push denied: " + "phase file restrictions'` and include this message. " + f"Blocked files: {phase_result.blocked_files}" + ) + elif has_non_state_files: + hint = ( + "Branch contains files outside .egg-state/ from a previous phase. " + "Create a clean branch from origin/main with only your state files." + ) + else: + hint = f"Phase '{session_phase}' has file restrictions. Check allowed patterns." + return make_error( + f"Push denied: {phase_result.message}", + status_code=403, + details={ + "phase": session_phase, + "blocked_files": phase_result.blocked_files, + "blocked_reason": phase_result.blocked_reason, + "hint": hint, + }, + ) + + # Get authentication token using shared helper + token_str, auth_mode, token_error = _b().get_token_for_repo(repo) + if not token_str: + return make_error(token_error, status_code=503) + + # Build push command with safe.directory for worktree paths + # Convert SSH URLs to HTTPS since gateway uses token auth + push_target = _b().get_authenticated_remote_target(remote, remote_url) + if push_target != remote: + logger.debug( + "Converting SSH URL to HTTPS for push", + original_url=remote_url, + https_url=push_target, + ) + # SECURITY: Belt-and-suspenders hook prevention. The primary protection is + # core.hooksPath=/dev/null in git_cmd() which disables ALL hooks globally. + # --no-verify is added as defense-in-depth for the pre-push hook. See issue #58. + push_args = ["push", "--no-verify"] + if force_with_lease: + # ``--force-with-lease`` rejects the push if the remote has moved + # since we last fetched it — preferred over ``--force`` for + # non-fast-forward pushes (e.g. the stacked-PR reconciler's + # rebase-then-push heal path, #2137). + push_args.append("--force-with-lease") + elif force: + push_args.append("--force") + # NOTE: The push uses the original refspec (not a SHA-based refspec) + # because it never calls ``update-ref`` pre-push, so the directory- + # style ref collision (sibling worktree refs like + # ``refs/heads//work``) does not apply here. See #1994. + push_args.extend([push_target, refspec] if refspec else [push_target]) + # Clear any http.extraheader from .git/config to ensure the gateway's + # credential helper (GIT_ASKPASS) is used. actions/checkout@v4 persists + # GITHUB_TOKEN as an extraheader by default, which takes precedence over + # GIT_ASKPASS and may lack permissions (e.g., workflows scope). + cmd = git_cmd("-c", "http.extraheader=", *push_args) + + # NOTE: Git author/committer info is set at COMMIT time, not push time. + # For user mode, the user must configure their local git: + # git config user.name "Your Name" + # git config user.email "your@email.com" + if auth_mode == "user": + logger.debug("User mode push", repo=repo) + + # Create credential helper and execute push + credential_helper_path = None + try: + credential_helper_path, env = create_credential_helper(token_str, os.environ.copy()) + + result = subprocess.run( + cmd, + cwd=exec_path, + capture_output=True, + text=True, + timeout=120, + env=env, + check=False, + ) + + if result.returncode == 0: + _b().audit_log( + "push_success", + "git_push", + success=True, + details={ + "repo": repo, + "branch": branch, + "force": force, + "auth_mode": auth_mode, + }, + ) + + # Update session bookkeeping after a successful push so other + # request handlers can resolve the session's current worktree. + session = getattr(g, "session", None) + if session is not None: + session.last_repo_path = exec_path + session.last_branch = branch + + success_payload: dict[str, Any] = { + "repo": repo, + "branch": branch, + "stdout": result.stdout, + "stderr": result.stderr, + "auth_mode": auth_mode, + } + # Surface pulled_commits / filtered=False on plain pushes so + # agents get consistent response shape across paths (#1882). + if auto_filter_response is not None: + success_payload.setdefault("filtered", auto_filter_response.get("filtered", False)) + success_payload.setdefault("nothing_to_push", False) + success_payload.setdefault( + "excluded_files", auto_filter_response.get("excluded_files", []) + ) + success_payload.setdefault( + "pushed_files", auto_filter_response.get("pushed_files", []) + ) + success_payload.setdefault( + "pulled_commits", auto_filter_response.get("pulled_commits", []) + ) + return make_success( + "Push successful", + success_payload, + ) + else: + _b().audit_log( + "push_failed", + "git_push", + success=False, + details={ + "repo": repo, + "branch": branch, + "returncode": result.returncode, + "auth_mode": auth_mode, + }, + ) + return make_error( + f"Push failed: {result.stderr}", + status_code=500, + details={"stdout": result.stdout, "stderr": result.stderr}, + ) + + except subprocess.TimeoutExpired: + return make_error("Push timed out", status_code=504) + except Exception as e: + return make_error(f"Push failed: {e}", status_code=500) + finally: + cleanup_credential_helper(credential_helper_path) + + +LS_REMOTE_VALUE_FLAGS: frozenset[str] = frozenset({"--sort"}) + + +def git_fetch() -> tuple[Response, int] | Response: + """ + Handle git fetch requests. + + Required because the container doesn't have direct access to GitHub tokens + (they are held by the gateway sidecar). This endpoint provides authenticated + fetch for git fetch, git ls-remote, and similar read operations. + + Request body: + { + "repo_path": "/path/to/repo", + "remote": "origin", + "args": ["--tags"] # optional additional args + } + + For ls-remote: + { + "repo_path": "/path/to/repo", + "operation": "ls-remote", + "remote": "origin", + "args": ["HEAD"] # optional refs to query + } + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + repo_path = data.get("repo_path") + remote = data.get("remote", "origin") + operation = data.get("operation", "fetch") # fetch or ls-remote + extra_args = data.get("args", []) + container_id = data.get("container_id") + + if not repo_path: + return make_error("Missing repo_path") + + # Validate repo_path to prevent path traversal attacks + path_valid, path_error = _b().validate_repo_path(repo_path) + if not path_valid: + _b().audit_log( + "fetch_blocked", + "git_fetch", + success=False, + details={"repo_path": repo_path, "reason": path_error}, + ) + return make_error(path_error, status_code=403) + + if operation not in ("fetch", "ls-remote"): + return make_error(f"Unsupported operation: {operation}") + + # Validate extra args against operation-specific allowlist + args_valid, args_error, validated_args = validate_git_args(operation, extra_args) + if not args_valid: + _b().audit_log( + "fetch_blocked", + "git_fetch", + success=False, + details={"reason": args_error, "operation": operation}, + ) + return make_error(args_error, status_code=400) + + # Map container path to worktree path if container_id is provided + exec_path = _b().map_container_path_to_worktree(repo_path, container_id, operation) + if exec_path is None: + return make_worktree_not_found_error(container_id) + + # Get remote URL to determine repo + remote_url, url_error = _b().resolve_remote_url(remote, exec_path) + if url_error: + return make_error(url_error) + + # Extract repo from URL + repo = extract_repo_from_remote(remote_url) + if not repo: + return make_error(f"Could not parse repository from URL: {remote_url}") + + # Get session mode from request context (set by @require_session_auth decorator) + session_mode = getattr(g, "session_mode", None) + + # Check Private Repo Mode policy (if enabled) + repo_info = parse_owner_repo(repo) + if repo_info: + priv_result = _b().check_private_repo_access( + operation=operation, + owner=repo_info.owner, + repo=repo_info.repo, + for_write=False, + session_mode=session_mode, + ) + if not priv_result.allowed: + _b().audit_log( + f"{operation}_denied_private_mode", + f"git_{operation}", + success=False, + details={ + "repo": repo, + "reason": priv_result.reason, + "visibility": priv_result.visibility, + }, + ) + return make_error( + priv_result.reason, + status_code=403, + details=priv_result.to_dict(), + ) + + # Get authentication token using shared helper + token_str, auth_mode, token_error = _b().get_token_for_repo(repo) + if not token_str: + return make_error(token_error, status_code=503) + + # Convert SSH URLs to HTTPS since gateway uses token auth + fetch_target = _b().get_authenticated_remote_target(remote, remote_url) + if fetch_target != remote: + logger.debug( + f"Converting SSH URL to HTTPS for {operation}", + original_url=remote_url, + https_url=fetch_target, + ) + + # Build command using validated args + if operation == "fetch": + # Don't include remote when --all is specified (fetches from all remotes) + if "--all" in validated_args: + cmd_args = ["fetch"] + validated_args + else: + cmd_args = ["fetch", fetch_target] + validated_args + else: # ls-remote + # ``git ls-remote`` stops option parsing at the first positional + # argument: anything after is a pattern, not a + # flag. ``ls-remote --heads`` therefore filters by the + # literal pattern "--heads", matching nothing and exiting 0 with + # empty output (#3479: the stacked-PR reconciler read that empty + # listing as "every branch deleted" and hot-looped rebases of + # healthy PRs). Emit flags before the repository and ref patterns + # after it. + # + # A naive startswith("-") partition would strand a separate- + # argument flag *value* after the URL as a bogus ref pattern + # (#3484 review note 1): ``--sort committerdate`` is an allowlisted + # ls-remote flag whose value does not start with "-", so it would + # become ``ls-remote --sort committerdate`` — ``committerdate`` + # silently matching nothing. Keep such a value adjacent to its flag + # on the pre-URL side. No caller passes the separate-value form + # today; this hardens the route against a future footgun. + flags: list[str] = [] + patterns: list[str] = [] + arg_idx = 0 + while arg_idx < len(validated_args): + token = validated_args[arg_idx] + if not token.startswith("-"): + patterns.append(token) + arg_idx += 1 + continue + flags.append(token) + # Inline ``--sort=key`` is self-contained; only the separate + # ``--sort key`` form needs its value pulled along with it. + takes_separate_value = ( + token.split("=", 1)[0] in LS_REMOTE_VALUE_FLAGS and "=" not in token + ) + if ( + takes_separate_value + and arg_idx + 1 < len(validated_args) + and not validated_args[arg_idx + 1].startswith("-") + ): + flags.append(validated_args[arg_idx + 1]) + arg_idx += 2 + continue + arg_idx += 1 + cmd_args = ["ls-remote", *flags, fetch_target, *patterns] + + cmd = git_cmd(*cmd_args) + + # Create credential helper and execute operation + credential_helper_path = None + try: + credential_helper_path, env = create_credential_helper(token_str, os.environ.copy()) + + result = subprocess.run( + cmd, + cwd=exec_path, + capture_output=True, + text=True, + timeout=120, + env=env, + check=False, + ) + + if result.returncode == 0: + _b().audit_log( + f"{operation}_success", + f"git_{operation}", + success=True, + details={ + "repo": repo, + "auth_mode": auth_mode, + }, + ) + return make_success( + f"{operation.capitalize()} successful", + { + "repo": repo, + "stdout": result.stdout, + "stderr": result.stderr, + "auth_mode": auth_mode, + }, + ) + else: + _b().audit_log( + f"{operation}_failed", + f"git_{operation}", + success=False, + details={ + "repo": repo, + "returncode": result.returncode, + "auth_mode": auth_mode, + }, + ) + return make_error( + f"{operation.capitalize()} failed: {result.stderr}", + status_code=500, + details={"stdout": result.stdout, "stderr": result.stderr}, + ) + + except subprocess.TimeoutExpired: + _b()._cleanup_stale_pack_files(exec_path) + return make_error(f"{operation.capitalize()} timed out", status_code=504) + except Exception as e: + return make_error(f"{operation.capitalize()} failed: {e}", status_code=500) + finally: + cleanup_credential_helper(credential_helper_path) diff --git a/gateway/gateway/_health.py b/gateway/gateway/_health.py new file mode 100644 index 0000000000..c76de6de15 --- /dev/null +++ b/gateway/gateway/_health.py @@ -0,0 +1,253 @@ +"""Gateway health cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from flask import Response, has_request_context, jsonify, request + +try: + from ..confluence_credentials import ( + reload_confluence_credentials, + ) + from ..confluence_policy import ( + reload_confluence_policy, + ) + from ..jira_credentials import ( + reload_jira_credentials, + ) + from ..jira_policy import ( + reload_jira_policy, + ) + from ..policy import ( + reload_policy_caches, + ) +except ImportError: # flat/container import mode + from confluence_credentials import ( # type: ignore[no-redef, import-untyped] + reload_confluence_credentials, + ) + from confluence_policy import ( # type: ignore[no-redef, import-untyped] + reload_confluence_policy, + ) + from jira_credentials import ( # type: ignore[no-redef, import-untyped] + reload_jira_credentials, + ) + from jira_policy import ( # type: ignore[no-redef, import-untyped] + reload_jira_policy, + ) + from policy import ( # type: ignore[no-redef, import-untyped] + reload_policy_caches, + ) + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + + +def get_proxy_ca_cert() -> tuple[Response, int] | Response: + """Serve the gateway proxy CA certificate (no auth required). + + The CA is public key material — every sandbox already receives it in + its trust store via the shared-certs volume in Compose mode. Under + k8s the agent Job ``command`` overrides the image ENTRYPOINT, so the + sandbox's ``setup_gateway_ca()`` never runs and no shared volume is + mounted; the one-shot event wrapper + (``orchestrator/consensus_wrapper.py``, #3459) fetches the current + CA from this endpoint per spawn and exports ``NODE_EXTRA_CA_CERTS``, + so agents validate TLS-bumped hosts (e.g. the GitHub Packages npm + read-through, #3456) without hand-wiring. Ad-hoc clients outside an + agent pod fetch the same way:: + + curl -sf "$GATEWAY_URL/api/v1/proxy/ca-cert" -o /tmp/gateway-ca.crt + NODE_EXTRA_CA_CERTS=/tmp/gateway-ca.crt pnpm install ... + + Serving it per-request also stays correct across gateway restarts, + which regenerate the CA (generate-ca-cert.sh). + """ + ca_path = Path("/etc/squid/certs/gateway-ca.crt") + if not ca_path.is_file(): + return jsonify({"error": "ca_cert_unavailable"}), 404 + return Response(ca_path.read_text(), mimetype="application/x-pem-file") + + +def health_check() -> Response: + """Health check endpoint (no auth required).""" + github = _b().get_github_client() + token_valid = github.is_token_valid() + + # Check launcher secret is configured + try: + _b().get_launcher_secret() + launcher_secret_configured = True + except _b().LauncherSecretNotConfiguredError: + launcher_secret_configured = False + + # Get session manager stats + session_manager = _b().get_session_manager() + active_sessions = len(session_manager.list_sessions()) + + # Check orchestrator connectivity (if configured) + orchestrator_status = _b()._check_orchestrator_connectivity() + + # Check Squid proxy health + squid_status = _b()._check_squid_health() + + # Gateway always runs with locked Squid. + # Per-container mode is enforced at container start via network selection. + # - Private containers: isolated network + proxy (locked to api.anthropic.com) + # - Public containers: external network + direct internet (no proxy) + # + # Status is "degraded" if Squid is down - private containers will be unable + # to reach the internet. Previously invisible because health check only + # verified the Python gateway (port 9848), not Squid (port 3129). + # See: https://github.com/jwbron/egg/issues/1387 + is_healthy = token_valid and launcher_secret_configured and squid_status["listening"] + + # Record this observation so the snapshot can expose transitions (see #1855). + _b()._health_tracker.record(is_healthy) + tracker_snapshot = _b()._health_tracker.snapshot() + + response_data: dict[str, Any] = { + "status": "healthy" if is_healthy else "degraded", + "github_token_valid": token_valid, + "auth_configured": launcher_secret_configured, + "squid_proxy": squid_status, + "active_sessions": active_sessions, + "service": "gateway", + "client_ip": request.remote_addr, + "process_start_time": tracker_snapshot["process_start_time"], + "healthy_since": tracker_snapshot["healthy_since"], + "last_unhealthy_at": tracker_snapshot["last_unhealthy_at"], + "recent_transitions": tracker_snapshot["recent_transitions"], + } + + # Include orchestrator status if configured + if orchestrator_status.get("configured"): + response_data["orchestrator"] = orchestrator_status + + return jsonify(response_data) + + +def _reload_all_config() -> None: + """Reload all cached configuration from disk/environment. + + Called by the SIGHUP handler and the /api/v1/config/reload endpoint. + + Thread safety: all cached values are immutable types (frozenset, tuple, + None) and global variable assignment is atomic under CPython's GIL, so + concurrent readers see either the old or new value, never a torn state. + Avoid replacing any cache with a mutable type (e.g. dict) without adding + synchronisation. + """ + try: + from config.repo_config import reload_config as reload_repo_config + except ImportError: + try: + from repo_config import reload_config as reload_repo_config # type: ignore[no-redef] + except ImportError: + reload_repo_config = None # type: ignore[assignment] + + if reload_repo_config is not None: + try: + reload_repo_config() + finally: + reload_policy_caches() + logger.info("Configuration reloaded") + else: + reload_policy_caches() + logger.warning("Policy caches reloaded (repo_config unavailable)") + + # Jira credentials + project allowlist — both sit on disk next to the + # other gateway config, so a single ``POST /api/v1/config/reload`` should + # refresh them alongside the GitHub policy caches. Failing the Jira + # reload must not tank the endpoint (operators may be running without + # Jira configured), so we log and continue. + try: + reload_jira_credentials() + except Exception: # pragma: no cover — defensive + logger.exception("Jira credentials reload failed") + try: + reload_jira_policy() + except Exception: # pragma: no cover — defensive + logger.exception("Jira project allowlist reload failed") + # ``_reload_all_config`` is reachable from two call sites: (a) the + # ``POST /api/v1/config/reload`` endpoint, which runs inside a Flask + # request; and (b) the SIGHUP handler, which does NOT. ``audit_log`` + # dereferences ``request.remote_addr`` so calling it outside a request + # raises ``RuntimeError: Working outside of request context``. Gate + # the audit on ``has_request_context`` so HTTP reloads still audit and + # SIGHUP falls back to a bare logger line. + if has_request_context(): + _b().audit_log( + "jira_config_reloaded", + "config_reload", + success=True, + details={"components": ["jira_credentials", "jira_policy"]}, + ) + else: + logger.info( + "Jira configuration reloaded", + components=["jira_credentials", "jira_policy"], + trigger="sighup", + ) + + # Confluence credentials + space allowlist — same disk-cache pattern as + # Jira. The Confluence allowlist lives under the ``confluence:`` section + # of context-filters.yaml; credentials share the secrets.env file. + try: + reload_confluence_credentials() + except Exception: # pragma: no cover — defensive + logger.exception("Confluence credentials reload failed") + try: + reload_confluence_policy() + except Exception: # pragma: no cover — defensive + logger.exception("Confluence space allowlist reload failed") + if has_request_context(): + _b().audit_log( + "confluence_config_reloaded", + "config_reload", + success=True, + details={"components": ["confluence_credentials", "confluence_policy"]}, + ) + else: + logger.info( + "Confluence configuration reloaded", + components=["confluence_credentials", "confluence_policy"], + trigger="sighup", + ) + + +def config_reload() -> Response: + """Reload configuration from disk. + + Clears all in-memory config caches so the next access re-reads from + repositories.yaml and environment variables. Requires launcher auth. + """ + _b()._reload_all_config() + return jsonify({"status": "ok", "message": "Configuration reloaded"}) diff --git a/gateway/gateway/_helpers.py b/gateway/gateway/_helpers.py new file mode 100644 index 0000000000..62b01a1156 --- /dev/null +++ b/gateway/gateway/_helpers.py @@ -0,0 +1,187 @@ +"""Gateway helpers cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import json +import os +import socket +import subprocess +from datetime import UTC, datetime +from typing import Any + +from flask import Response, jsonify, request + +try: + from .._module_loader import ( + load_sibling_gateway_module as _load_sibling_gateway_module, + ) +except ImportError: # flat/container import mode + from _module_loader import ( # type: ignore[no-redef, import-untyped] + load_sibling_gateway_module as _load_sibling_gateway_module, + ) + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + + +def _lookup_commit_observer_fn(name: str) -> Any: + """Return a callable from ``commit_observer`` without relative imports.""" + mod = _load_sibling_gateway_module("commit_observer") + if mod is None: + return None + return getattr(mod, name, None) + + +def make_response( + success: bool, + message: str, + data: dict[str, Any] | None = None, + status_code: int = 200, +) -> tuple[Response, int]: + """Create a standardized JSON response.""" + response = {"success": success, "message": message} + if data: + response["data"] = data + return jsonify(response), status_code + + +def make_error( + message: str, status_code: int = 400, details: dict[str, Any] | None = None +) -> tuple[Response, int]: + """Create an error response.""" + return make_response(False, message, details, status_code) + + +def make_success(message: str, data: dict[str, Any] | None = None) -> tuple[Response, int]: + """Create a success response.""" + return make_response(True, message, data, 200) + + +def make_worktree_not_found_error(container_id: str) -> tuple[Response, int]: + """Return a 500 error when a container's worktree cannot be found. + + This prevents the silent fallback to the main repo that caused #1497: + agents could not see their own file changes because git ran against + the main repo instead of the agent's worktree. + """ + return make_error( + f"Worktree not found for container '{container_id}'. " + "The per-agent worktree may not have been created. " + "Git operations require a valid worktree.", + status_code=500, + ) + + +def audit_log( + event_type: str, + operation: str, + success: bool, + details: dict[str, Any] | None = None, +) -> None: + """Log an audit event in structured format.""" + log_data: dict[str, Any] = { + "timestamp": datetime.now(UTC).isoformat(), + "event_type": "gateway_operation", + "operation": operation, + "source_ip": request.remote_addr, + "success": success, + } + if details: + log_data.update(details) + + if success: + logger.info(f"Audit: {event_type}", **log_data) + else: + logger.warning(f"Audit: {event_type}", **log_data) + + +def _check_orchestrator_connectivity() -> dict[str, Any]: + """Check orchestrator connectivity if configured. + + Returns: + Dictionary with orchestrator status. Contains {"configured": False} + if orchestrator URL is not set, otherwise includes reachability info. + """ + orchestrator_url = os.environ.get("EGG_ORCHESTRATOR_URL") + if not orchestrator_url: + return {"configured": False} + + try: + # Use a short timeout for health checks + import urllib.request + + health_url = f"{orchestrator_url}/api/v1/health" + req = urllib.request.Request(health_url, method="GET") + with urllib.request.urlopen(req, timeout=2) as response: + data = json.loads(response.read().decode()) + return { + "configured": True, + "reachable": True, + "url": orchestrator_url, + "status": data.get("status", "unknown"), + } + except Exception as e: + return { + "configured": True, + "reachable": False, + "error": str(e), + } + + +def _check_squid_health() -> dict[str, Any]: + """Check if Squid proxy is running and listening on port 3129. + + Returns a dict with squid health info: + running: bool - True if the squid process is alive + listening: bool - True if port 3129 is accepting connections + """ + result: dict[str, Any] = {"running": False, "listening": False} + + # Check if squid process is running (not zombie) + try: + proc = subprocess.run( + ["pgrep", "-x", "squid"], + capture_output=True, + timeout=5, + ) + result["running"] = proc.returncode == 0 + except subprocess.TimeoutExpired, FileNotFoundError: + pass + + # Check if squid is actually accepting connections on port 3129. + # We use a direct TCP connect instead of 'squid -k check' because the + # latter re-parses squid.conf and fails when run as non-root (can't read + # the SSL private key), even though Squid itself is running fine. + try: + with socket.create_connection(("127.0.0.1", 3129), timeout=2): + result["listening"] = True + except OSError: + pass + + return result diff --git a/gateway/gateway/_jira.py b/gateway/gateway/_jira.py new file mode 100644 index 0000000000..4ca9677786 --- /dev/null +++ b/gateway/gateway/_jira.py @@ -0,0 +1,847 @@ +"""Gateway jira cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import re +import secrets +from typing import Any + +from flask import Response, g, request + +try: + from ..jira_client import ( + JiraCredentialsUnavailable, + JiraUpstreamError, + validate_jira_api_path, + ) + from ..jira_client import ( + validate_fields as validate_jira_fields, + ) + from ..jira_policy import ( + extract_project_key, + ) + from ..jira_search import ( + extract_search_projects, + ) +except ImportError: # flat/container import mode + from jira_client import ( # type: ignore[no-redef, import-untyped] + JiraCredentialsUnavailable, + JiraUpstreamError, + validate_jira_api_path, + ) + from jira_client import ( # type: ignore[no-redef, import-untyped] + validate_fields as validate_jira_fields, + ) + from jira_policy import ( # type: ignore[no-redef, import-untyped] + extract_project_key, + ) + from jira_search import ( # type: ignore[no-redef, import-untyped] + extract_search_projects, + ) + +from ._helpers import make_error, make_success + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + +_JIRA_TICKET_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*-\d+$") + + +_JIRA_PROJECT_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") + + +def _session_jira_context() -> dict[str, Any]: + """Return session-scoped fields to include in Jira audit records. + + Pipeline ID, agent role, and the new ``jira_ticket`` are observational + — they aren't used as policy gates (the project allowlist is the only + hard boundary — refine decision #9) but they make the audit trail + self-describing. + """ + ctx: dict[str, Any] = { + "session_mode": getattr(g, "session_mode", None), + } + session = getattr(g, "session", None) + if session is not None: + ctx["pipeline_id"] = getattr(session, "pipeline_id", None) + ctx["agent_role"] = getattr(session, "agent_role", None) + ctx["jira_ticket"] = getattr(session, "jira_ticket", None) + return ctx + + +def _jira_error_from_upstream(exc: JiraUpstreamError) -> tuple[Response, int]: + """Translate a ``JiraUpstreamError`` to an HTTP response. + + Atlassian status codes in the 4xx range are passed through so the agent + sees the real reason; 5xx upstream errors collapse to a 502 with the + raw body in the audit trail. + """ + if 400 <= exc.status_code < 500: + status = exc.status_code + else: + status = 502 + return make_error( + f"Jira upstream error {exc.status_code}", + status_code=status, + details={ + "upstream_status": exc.status_code, + "upstream_body": exc.body, + "path": exc.path, + }, + ) + + +def _jira_not_configured_error(exc: JiraCredentialsUnavailable) -> tuple[Response, int]: + """Translate missing credentials to an HTTP 503 response.""" + return make_error( + "Jira credentials not configured on the gateway", + status_code=503, + details={"reason": str(exc)}, + ) + + +def _project_not_allowlisted_response( + *, + event: str, + ticket: str | None, + project: str | None, + reason: str, + extra: dict[str, Any] | None = None, +) -> tuple[Response, int]: + """Emit a structured audit record and return the canonical 403.""" + details: dict[str, Any] = {"project": project, "reason": reason} + if ticket is not None: + details["ticket"] = ticket + if extra: + details.update(extra) + details.update(_session_jira_context()) + _b().audit_log(event, event, success=False, details=details) + return make_error( + "Jira project not allowlisted", + status_code=403, + details={"project": project, "reason": reason}, + ) + + +def jira_ticket_get() -> tuple[Response, int] | Response: + """Fetch a single Jira issue. + + Request body:: + + {"ticket": "FOO-123", "fields": ["summary", "status"]} + + ``fields`` is optional; when omitted, Atlassian returns the default field + set. ``expand`` defaults to ``renderedBody,renderedFields`` in the + client so agents receive both ADF and rendered HTML. + """ + data = request.get_json(silent=True) or {} + ticket = data.get("ticket") + fields = data.get("fields") + + if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): + _b().audit_log( + "jira_ticket_get_rejected", + "jira_ticket_get", + success=False, + details={"reason": "invalid ticket shape", "ticket": ticket, **_session_jira_context()}, + ) + return make_error( + "Invalid ticket key (expected e.g. 'FOO-123')", + status_code=400, + details={"ticket": ticket}, + ) + + project = extract_project_key(ticket) + if not _b().is_project_allowed(project): + return _project_not_allowlisted_response( + event="jira_ticket_get_denied", + ticket=ticket, + project=project, + reason="project not allowlisted", + ) + + try: + cleaned_fields = validate_jira_fields(fields) + except ValueError as exc: + _b().audit_log( + "jira_ticket_get_rejected", + "jira_ticket_get", + success=False, + details={"reason": str(exc), "ticket": ticket, **_session_jira_context()}, + ) + return make_error(f"Invalid fields: {exc}", status_code=400) + + try: + body = _b().get_jira_client().get_ticket(ticket, cleaned_fields or None) + except JiraCredentialsUnavailable as exc: + return _jira_not_configured_error(exc) + except JiraUpstreamError as exc: + _b().audit_log( + "jira_ticket_get_upstream_error", + "jira_ticket_get", + success=False, + details={ + "ticket": ticket, + "project": project, + "upstream_status": exc.status_code, + **_session_jira_context(), + }, + ) + return _jira_error_from_upstream(exc) + + _b().audit_log( + "jira_ticket_get", + "jira_ticket_get", + success=True, + details={ + "ticket": ticket, + "project": project, + "not_found": body.get("status") == "not_found", + **_session_jira_context(), + }, + ) + return make_success("Jira ticket fetched", body) + + +def jira_search() -> tuple[Response, int] | Response: + """Run a JQL query against Atlassian Cloud. + + Request body:: + + {"jql": "project = ENG AND status = Open", + "fields": [...], + "nextPageToken": "...", + "maxResults": 50} + + The JQL must be statically provable as scoped to allowlisted projects. + See ``gateway/jira_search.py`` for the exact acceptance rules. + """ + data = request.get_json(silent=True) or {} + jql = data.get("jql") + fields = data.get("fields") + next_page_token = data.get("nextPageToken") + max_results = data.get("maxResults") + + if not isinstance(jql, str) or not jql.strip(): + _b().audit_log( + "jira_search_rejected", + "jira_search", + success=False, + details={"reason": "jql required", **_session_jira_context()}, + ) + return make_error("jql is required", status_code=400) + + # Import allowlist lazily because ``allowed_projects`` resolves the + # policy singleton on first access. Getting the frozenset once per + # request keeps the mtime check out of the hot path for tests that + # monkeypatch ``is_project_allowed`` directly. + try: + from ..jira_policy import allowed_projects + except ImportError: + from jira_policy import allowed_projects # type: ignore[no-redef] + allowed = allowed_projects() + + scope = extract_search_projects(jql, allowed) + if scope.projects is None: + _b().audit_log( + "jira_search_rejected", + "jira_search", + success=False, + details={ + "reason": scope.reason, + "jql_length": len(jql), + **_session_jira_context(), + }, + ) + return make_error( + f"JQL rejected: {scope.reason}", + status_code=403, + details={"reason": scope.reason}, + ) + + try: + cleaned_fields = validate_jira_fields(fields) + except ValueError as exc: + _b().audit_log( + "jira_search_rejected", + "jira_search", + success=False, + details={"reason": str(exc), **_session_jira_context()}, + ) + return make_error(f"Invalid fields: {exc}", status_code=400) + + # Normalise max_results: accept an int or a string-that-parses. Missing + # / invalid falls back to the client-side default (50, capped at 100). + effective_max: int | None = None + if max_results is not None: + try: + effective_max = max(1, min(int(max_results), 100)) + except TypeError, ValueError: + _b().audit_log( + "jira_search_rejected", + "jira_search", + success=False, + details={ + "reason": "maxResults must be an integer", + **_session_jira_context(), + }, + ) + return make_error("maxResults must be an integer", status_code=400) + + try: + body = ( + _b() + .get_jira_client() + .search( + jql=jql, + fields=cleaned_fields or None, + next_page_token=next_page_token if isinstance(next_page_token, str) else None, + max_results=effective_max, + ) + ) + except JiraCredentialsUnavailable as exc: + return _jira_not_configured_error(exc) + except JiraUpstreamError as exc: + _b().audit_log( + "jira_search_upstream_error", + "jira_search", + success=False, + details={ + "upstream_status": exc.status_code, + **_session_jira_context(), + }, + ) + return _jira_error_from_upstream(exc) + + _b().audit_log( + "jira_search", + "jira_search", + success=True, + details={ + "projects_extracted": sorted(scope.projects), + "jql_length": len(jql), + "max_results": effective_max, + "next_page_token_present": bool(next_page_token), + **_session_jira_context(), + }, + ) + return make_success("Jira search executed", body) + + +def jira_ticket_comments() -> tuple[Response, int] | Response: + """Fetch comments for a Jira issue.""" + data = request.get_json(silent=True) or {} + ticket = data.get("ticket") + + if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): + _b().audit_log( + "jira_ticket_comments_rejected", + "jira_ticket_comments", + success=False, + details={"reason": "invalid ticket shape", "ticket": ticket, **_session_jira_context()}, + ) + return make_error( + "Invalid ticket key (expected e.g. 'FOO-123')", + status_code=400, + details={"ticket": ticket}, + ) + + project = extract_project_key(ticket) + if not _b().is_project_allowed(project): + return _project_not_allowlisted_response( + event="jira_ticket_comments_denied", + ticket=ticket, + project=project, + reason="project not allowlisted", + ) + + try: + body = _b().get_jira_client().get_comments(ticket) + except JiraCredentialsUnavailable as exc: + return _jira_not_configured_error(exc) + except JiraUpstreamError as exc: + _b().audit_log( + "jira_ticket_comments_upstream_error", + "jira_ticket_comments", + success=False, + details={ + "ticket": ticket, + "project": project, + "upstream_status": exc.status_code, + **_session_jira_context(), + }, + ) + return _jira_error_from_upstream(exc) + + _b().audit_log( + "jira_ticket_comments", + "jira_ticket_comments", + success=True, + details={ + "ticket": ticket, + "project": project, + "not_found": body.get("status") == "not_found", + **_session_jira_context(), + }, + ) + return make_success("Jira ticket comments fetched", body) + + +def jira_ticket_remotelinks() -> tuple[Response, int] | Response: + """Fetch the remote-link list for a Jira issue (issue #1557 slice-2). + + Request body:: + + {"ticket": "FOO-123"} + + Read-only — wraps the Atlassian ``GET /rest/api/3/issue/{key}/ + remotelink`` endpoint. Used by the orchestrator's reassess + sweep's in-flight classifier (decision-7 signal b) and the + sandbox ``jira ticket remotelinks `` CLI subcommand to + catch human-opened PRs that the orchestrator's reverse-index + doesn't track. Inherits the same project-allowlist boundary as + every other Jira route — ``JIRA_WRITE_VERBS_DENIED`` and + ``validate_jira_api_path`` keep the path GET-only. + """ + data = request.get_json(silent=True) or {} + ticket = data.get("ticket") + + if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): + _b().audit_log( + "jira_ticket_remotelinks_rejected", + "jira_ticket_remotelinks", + success=False, + details={ + "reason": "invalid ticket shape", + "ticket": ticket, + **_session_jira_context(), + }, + ) + return make_error( + "Invalid ticket key (expected e.g. 'FOO-123')", + status_code=400, + details={"ticket": ticket}, + ) + + project = extract_project_key(ticket) + if not _b().is_project_allowed(project): + return _project_not_allowlisted_response( + event="jira_ticket_remotelinks_denied", + ticket=ticket, + project=project, + reason="project not allowlisted", + ) + + try: + body = _b().get_jira_client().get_remotelinks(ticket) + except JiraCredentialsUnavailable as exc: + return _jira_not_configured_error(exc) + except JiraUpstreamError as exc: + _b().audit_log( + "jira_ticket_remotelinks_upstream_error", + "jira_ticket_remotelinks", + success=False, + details={ + "ticket": ticket, + "project": project, + "upstream_status": exc.status_code, + **_session_jira_context(), + }, + ) + return _jira_error_from_upstream(exc) + + _b().audit_log( + "jira_ticket_remotelinks", + "jira_ticket_remotelinks", + success=True, + details={ + "ticket": ticket, + "project": project, + "not_found": body.get("status") == "not_found", + "remotelink_count": len(body.get("remotelinks") or []) + if isinstance(body.get("remotelinks"), list) + else 0, + **_session_jira_context(), + }, + ) + return make_success("Jira remote links fetched", body) + + +_TRANSITION_ALLOWLIST: frozenset[str] = frozenset( + {name.lower() for name in ("Won't Do", "Won't Fix", "Wontfix")} +) + + +def _verify_orchestrator_transition_auth() -> tuple[bool, str]: + """Verify the caller of ``/api/v1/jira/ticket/transition`` is the + orchestrator (issue #1557 task-2-6). + + Two-factor check: + 1. ``Authorization: Bearer `` must validate + against the gateway's launcher secret. Note: sandbox pods + ALSO mount the launcher secret (it backs the standard + session-creation flow), so the bearer alone does not + distinguish orchestrator from sandbox — the loopback / + in-cluster check plus NetworkPolicy on the gateway pod + provides that scoping. See ``docs/architecture/ + orchestrator.md`` § "Trust model" for the full discussion. + 2. The request must originate from a loopback / in-cluster + source. We accept any caller whose source IP equals the + orchestrator's gateway-side IP, the loopback addresses + (``127.0.0.1`` / ``::1``), or anything in the cluster pod + subnet. This is a coarse RFC1918 check — it excludes + external traffic but does not by itself distinguish + orchestrator pods from sandbox pods. Without NetworkPolicy + restricting ``/transition`` ingress to the orchestrator's + pod selector, the launcher secret is the only remaining + barrier between a compromised sandbox and this route. + + Returns ``(ok, reason)``. + """ + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return False, "missing_bearer_auth" + presented = auth_header[len("Bearer ") :] + try: + launcher_secret = _b().get_launcher_secret() + except _b().LauncherSecretNotConfiguredError: + return False, "launcher_secret_not_configured" + if not launcher_secret or not secrets.compare_digest(presented, launcher_secret): + return False, "bad_bearer_auth" + + # Loopback / in-cluster source check. ``request.remote_addr`` is + # the immediate peer; for in-cluster traffic this is the + # orchestrator pod IP. We accept anything from RFC1918 / IPv6 + # link-local / loopback so the orchestrator can reach us via any + # ingress-side path (k3s NodePort, direct service IP, …). Public + # IPs are rejected. + remote_addr = request.remote_addr or "" + if not _is_in_cluster_source(remote_addr): + return False, "source_not_in_cluster" + + return True, "" + + +def _is_in_cluster_source(remote_addr: str) -> bool: + """Return True if ``remote_addr`` is a loopback / RFC1918 address.""" + if not remote_addr: + return False + try: + import ipaddress + + ip = ipaddress.ip_address(remote_addr) + except ValueError: + return False + if ip.is_loopback: + return True + if ip.is_private: + return True + if ip.is_link_local: + return True + return False + + +def jira_ticket_transition() -> tuple[Response, int] | Response: + """Transition a Jira issue (issue #1557 slice-2 task-2-6). + + **Orchestrator-only**. The agent-facing Jira surface continues to + deny transitions via ``JIRA_WRITE_VERBS_DENIED`` — this route + bypasses the agent path entirely. Auth is a two-factor check: + a launcher-secret bearer token AND a loopback / in-cluster + source IP. Transition names are restricted to the allowlist + (``Won't Do`` / ``Won't Fix``) — anything else returns 400. + + Request body:: + + {"ticket": "FOO-123", + "transition_name": "Won't Do", + "comment": "Consolidated into FOO-200"} + + Returns ``200 OK`` on success with the upstream status code in + the response body. Audit log entry covers caller IP, transition + name, ticket key, and outcome. + """ + ok, reason = _verify_orchestrator_transition_auth() + if not ok: + _b().audit_log( + "jira_ticket_transition_unauthorized", + "jira_ticket_transition", + success=False, + details={ + "reason": reason, + "remote_addr": request.remote_addr, + }, + ) + return make_error( + "Unauthorized — orchestrator-only route", + status_code=401 if reason != "source_not_in_cluster" else 403, + details={"reason": reason}, + ) + + data = request.get_json(silent=True) or {} + ticket = data.get("ticket") + transition_name = data.get("transition_name") + comment_text = data.get("comment") + + if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): + _b().audit_log( + "jira_ticket_transition_rejected", + "jira_ticket_transition", + success=False, + details={ + "reason": "invalid ticket shape", + "ticket": ticket, + }, + ) + return make_error( + "Invalid ticket key (expected e.g. 'FOO-123')", + status_code=400, + details={"ticket": ticket}, + ) + + if not isinstance(transition_name, str) or not transition_name.strip(): + return make_error( + "transition_name is required", + status_code=400, + details={"reason": "missing_transition_name"}, + ) + if transition_name.strip().lower() not in _TRANSITION_ALLOWLIST: + _b().audit_log( + "jira_ticket_transition_denied", + "jira_ticket_transition", + success=False, + details={ + "reason": "transition_not_allowlisted", + "transition_name": transition_name, + "ticket": ticket, + }, + ) + return make_error( + f"transition_name {transition_name!r} is not on the allowlist", + status_code=400, + details={ + "reason": "transition_not_allowlisted", + "allowed": sorted(_TRANSITION_ALLOWLIST), + }, + ) + + project = extract_project_key(ticket) + if not _b().is_project_allowed(project): + return _project_not_allowlisted_response( + event="jira_ticket_transition_denied", + ticket=ticket, + project=project, + reason="project not allowlisted", + ) + + comment_adf: dict[str, Any] | None = None + if isinstance(comment_text, str) and comment_text.strip(): + try: + from ..jira_adf import wrap_text_as_adf + except ImportError: + # Issue #1557 tester v1 lint finding: ``jira_adf`` ships + # without a ``py.typed`` marker so mypy reports it as + # ``import-untyped``. The companion import at line 5849 + # already uses the dual-ignore; mirror it here. + from jira_adf import wrap_text_as_adf # type: ignore[no-redef, import-untyped] + comment_adf = wrap_text_as_adf(comment_text.strip()) + + try: + status_code, body = ( + _b() + .get_jira_client() + .transition_issue( + ticket, + transition_name=transition_name.strip(), + comment_adf=comment_adf, + ) + ) + except JiraCredentialsUnavailable as exc: + return _jira_not_configured_error(exc) + except JiraUpstreamError as exc: + _b().audit_log( + "jira_ticket_transition_upstream_error", + "jira_ticket_transition", + success=False, + details={ + "ticket": ticket, + "project": project, + "transition_name": transition_name, + "upstream_status": exc.status_code, + }, + ) + return _jira_error_from_upstream(exc) + + _b().audit_log( + "jira_ticket_transition", + "jira_ticket_transition", + success=True, + details={ + "ticket": ticket, + "project": project, + "transition_name": transition_name, + "upstream_status": status_code, + "comment_attached": bool(comment_adf), + "remote_addr": request.remote_addr, + }, + ) + return make_success( + "Jira ticket transitioned", + {"upstream_status": status_code, "body": body}, + ) + + +def jira_execute() -> tuple[Response, int] | Response: + """Generic read-only passthrough for whitelisted Jira REST paths. + + Request body:: + + {"method": "GET", + "path": "issue/FOO-123", + "query": {"fields": "summary"}, + "body": null} + + Only methods + paths accepted by ``validate_jira_api_path`` are allowed. + Write verbs (DELETE/PUT/PATCH) and path fragments listed in + ``JIRA_WRITE_VERBS_DENIED`` are refused unconditionally. + """ + data = request.get_json(silent=True) or {} + method = data.get("method") or "GET" + path = data.get("path") + query = data.get("query") + req_body = data.get("body") + + if not isinstance(path, str) or not path: + _b().audit_log( + "jira_execute_rejected", + "jira_execute", + success=False, + details={"reason": "path required", **_session_jira_context()}, + ) + return make_error("path is required", status_code=400) + + if not isinstance(method, str): + _b().audit_log( + "jira_execute_rejected", + "jira_execute", + success=False, + details={"reason": "method must be a string", **_session_jira_context()}, + ) + return make_error("method must be a string", status_code=400) + + method_upper = method.upper() + ok, reason = validate_jira_api_path(path, method_upper) + if not ok: + _b().audit_log( + "jira_execute_denied", + "jira_execute", + success=False, + details={ + "method": method_upper, + "path": path, + "reason": reason, + **_session_jira_context(), + }, + ) + return make_error( + f"Jira API call rejected: {reason}", + status_code=403, + details={"method": method_upper, "path": path, "reason": reason}, + ) + + # Path is structurally OK — extract project key (if any) and allowlist it. + # The accepted shapes are ``issue/[/comment]`` and + # ``project/``. Both carry a project key inline that is checked + # against the allowlist. Bare ``project`` is excluded (would leak all + # projects visible to the API token). + stripped = path.strip("/").split("?", 1)[0] + ticket: str | None = None + project: str | None = None + head = stripped.split("/") + if head and head[0] == "issue" and len(head) >= 2: + ticket = head[1] + project = extract_project_key(ticket) + elif head and head[0] == "project" and len(head) >= 2: + project = head[1] + + if project is not None and not _b().is_project_allowed(project): + return _project_not_allowlisted_response( + event="jira_execute_denied", + ticket=ticket, + project=project, + reason="project not allowlisted", + extra={"method": method_upper, "path": path}, + ) + + # Normalise query & body — they must be dicts or None. + if query is not None and not isinstance(query, dict): + return make_error("query must be an object", status_code=400) + if req_body is not None and not isinstance(req_body, dict): + return make_error("body must be an object", status_code=400) + + try: + body = ( + _b() + .get_jira_client() + .execute_raw( + method=method_upper, + path=stripped, + query=query, + body=req_body, + ) + ) + except JiraCredentialsUnavailable as exc: + return _jira_not_configured_error(exc) + except JiraUpstreamError as exc: + _b().audit_log( + "jira_execute_upstream_error", + "jira_execute", + success=False, + details={ + "method": method_upper, + "path": stripped, + "upstream_status": exc.status_code, + **_session_jira_context(), + }, + ) + return _jira_error_from_upstream(exc) + + _b().audit_log( + "jira_execute", + "jira_execute", + success=True, + details={ + "method": method_upper, + "path": stripped, + "project": project, + "ticket": ticket, + **_session_jira_context(), + }, + ) + return make_success("Jira API call executed", body) diff --git a/gateway/gateway/_jira_writes.py b/gateway/gateway/_jira_writes.py new file mode 100644 index 0000000000..2f88cc9932 --- /dev/null +++ b/gateway/gateway/_jira_writes.py @@ -0,0 +1,964 @@ +"""Gateway jira_writes cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import json +from typing import Any + +from flask import Response, request + +try: + from ..jira_client import ( + JiraCredentialsUnavailable, + JiraUpstreamError, + ) + from ..jira_policy import ( + extract_project_key, + ) +except ImportError: # flat/container import mode + from jira_client import ( # type: ignore[no-redef, import-untyped] + JiraCredentialsUnavailable, + JiraUpstreamError, + ) + from jira_policy import ( # type: ignore[no-redef, import-untyped] + extract_project_key, + ) + +from ._helpers import make_error, make_success +from ._jira import ( + _JIRA_PROJECT_KEY_RE, + _JIRA_TICKET_KEY_RE, + _jira_error_from_upstream, + _jira_not_configured_error, + _project_not_allowlisted_response, + _session_jira_context, +) + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + +_JIRA_SUMMARY_MAX_CHARS: int = 255 + + +_JIRA_BODY_MAX_CHARS: int = 32 * 1024 + + +_JIRA_LABELS_MAX_COUNT: int = 30 + + +_JIRA_LABEL_MAX_CHARS: int = 50 + + +_JIRA_CREATE_ALLOWED_KEYS: frozenset[str] = frozenset( + { + "project", + "issuetype", + "summary", + "description", + "labels", + "parent", + "epicLink", + "idempotencyKey", + } +) + + +_JIRA_EDIT_ALLOWED_KEYS: frozenset[str] = frozenset( + { + "ticket", + "summary", + "description", + "labels", + "addLabels", + "removeLabels", + "notifyUsers", + } +) + + +_JIRA_COMMENT_ALLOWED_KEYS: frozenset[str] = frozenset( + { + "ticket", + "body", + "idempotencyKey", + } +) + + +_JIRA_LINK_ALLOWED_KEYS: frozenset[str] = frozenset( + { + "type", + "inwardIssue", + "outwardIssue", + "comment", + "idempotencyKey", + } +) + + +_JIRA_ALLOWED_ISSUETYPE_NAMES: frozenset[str] = frozenset( + {"Task", "Story", "Bug", "Epic", "Sub-task", "Subtask"} +) + + +def _jira_write_audit_meta(body: dict[str, Any]) -> dict[str, Any]: + """Return structural metadata for a write-verb audit record. + + Logs **field names changed**, **content lengths**, **label values**, and + **link-type names** (refine feedback Q5) — never raw body content. + """ + meta: dict[str, Any] = {} + fields_present: list[str] = [] + for key in ( + "summary", + "description", + "labels", + "addLabels", + "removeLabels", + "parent", + "epicLink", + "issuetype", + "project", + "ticket", + "body", + "comment", + "type", + "inwardIssue", + "outwardIssue", + ): + if key in body: + fields_present.append(key) + if fields_present: + meta["fields_present"] = fields_present + + summary = body.get("summary") + if isinstance(summary, str): + meta["summary_length"] = len(summary) + + description = body.get("description") + if isinstance(description, str): + meta["description_length"] = len(description) + elif isinstance(description, dict): + meta["description_length"] = -1 # ADF passthrough; length unknown + meta["description_kind"] = "adf" + + comment_body = body.get("body") + if isinstance(comment_body, str): + meta["body_length"] = len(comment_body) + elif isinstance(comment_body, dict): + meta["body_length"] = -1 + meta["body_kind"] = "adf" + + labels = body.get("labels") + if isinstance(labels, list): + meta["labels"] = [v for v in labels if isinstance(v, str)] + add_labels = body.get("addLabels") + if isinstance(add_labels, list): + meta["add_labels"] = [v for v in add_labels if isinstance(v, str)] + remove_labels = body.get("removeLabels") + if isinstance(remove_labels, list): + meta["remove_labels"] = [v for v in remove_labels if isinstance(v, str)] + + link_type = body.get("type") + if isinstance(link_type, str): + meta["link_type"] = link_type + + issuetype = body.get("issuetype") + if isinstance(issuetype, dict): + if isinstance(issuetype.get("name"), str): + meta["issuetype_name"] = issuetype["name"] + if isinstance(issuetype.get("id"), str): + meta["issuetype_id"] = issuetype["id"] + elif isinstance(issuetype, str): + meta["issuetype_name"] = issuetype + + return meta + + +def _validate_jira_write_keys( + body: dict[str, Any], allowed: frozenset[str], operation: str +) -> tuple[Response, int] | None: + """Reject unknown / suspect top-level body keys. + + Returns a 400 response when an unknown key is found (custom-field + smuggling, ``method``-tunnel attempts, or typos), otherwise ``None``. + """ + extras = sorted(set(body) - allowed) + if not extras: + return None + _b().audit_log( + f"{operation}_rejected", + operation, + success=False, + details={ + "reason": "unknown_body_keys", + "unknown_keys": extras, + **_session_jira_context(), + }, + ) + return make_error( + f"Unknown body keys: {extras}", + status_code=400, + details={"unknown_keys": extras}, + ) + + +def _validate_jira_text_field( + value: Any, + *, + field: str, + max_chars: int, + allow_adf: bool = False, +) -> tuple[str | dict[str, Any] | None, tuple[Response, int] | None]: + """Validate a string-or-ADF text field. + + Returns ``(cleaned_value, None)`` on success or + ``(None, error_response)`` on failure. ``None`` is treated as "not + supplied"; callers handle the optional vs required distinction. + """ + if value is None: + return None, None + + if allow_adf and isinstance(value, dict): + # ADF dict — ensure it's structurally valid; size cap applied to + # serialised length so a malicious nested ADF tree can't hide. + try: + from ..jira_adf import is_adf_dict + except ImportError: + from jira_adf import is_adf_dict # type: ignore[no-redef] + if not is_adf_dict(value): + return None, make_error( + f"{field} must be a string or a valid ADF document", + status_code=400, + ) + # Size check via serialised length as a proxy. + serialised = json.dumps(value) + if len(serialised) > max_chars: + return None, make_error( + f"{field} exceeds maximum length ({max_chars} chars)", + status_code=400, + ) + return value, None + + if not isinstance(value, str): + return None, make_error(f"{field} must be a string", status_code=400) + if len(value) > max_chars: + return None, make_error( + f"{field} exceeds maximum length ({max_chars} chars)", + status_code=400, + ) + return value, None + + +def _validate_jira_labels( + value: Any, *, field: str +) -> tuple[list[str] | None, tuple[Response, int] | None]: + """Validate a labels list (count cap + per-entry length cap).""" + if value is None: + return None, None + if not isinstance(value, list): + return None, make_error(f"{field} must be a list", status_code=400) + if len(value) > _JIRA_LABELS_MAX_COUNT: + return None, make_error( + f"{field} exceeds maximum of {_JIRA_LABELS_MAX_COUNT} entries", + status_code=400, + ) + cleaned: list[str] = [] + for entry in value: + if not isinstance(entry, str): + return None, make_error(f"{field} entries must be strings", status_code=400) + if not entry: + return None, make_error(f"{field} entries must be non-empty", status_code=400) + if len(entry) > _JIRA_LABEL_MAX_CHARS: + return None, make_error( + f"{field} entry exceeds maximum length ({_JIRA_LABEL_MAX_CHARS} chars)", + status_code=400, + ) + if " " in entry: + return None, make_error( + f"{field} entries must not contain whitespace", + status_code=400, + ) + cleaned.append(entry) + return cleaned, None + + +def jira_ticket_create() -> tuple[Response, int] | Response: + """Create a Jira issue via ``POST /rest/api/3/issue``. + + Request body:: + + {"project": "ENG", + "issuetype": "Task" | {"name": "Task"} | {"id": "10001"}, + "summary": "...", + "description": "..." | | null, + "labels": ["foo", "bar"], + "parent": "ENG-1" | null, + "epicLink": "ENG-2" | null, + "idempotencyKey": "..." | null} + + ``parent`` and ``epicLink`` are mutually exclusive. Cross-project + parents are rejected (refine decision-17). ``epicLink`` dispatches via + ``JiraPolicy.epic_link_field`` (``parent`` or ``customfield_10014``). + """ + operation = "jira_ticket_create" + data = request.get_json(silent=True) or {} + + if not isinstance(data, dict): + return make_error("body must be a JSON object", status_code=400) + + err = _validate_jira_write_keys(data, _JIRA_CREATE_ALLOWED_KEYS, operation) + if err is not None: + return err + + project = data.get("project") + issuetype = data.get("issuetype") + summary = data.get("summary") + description = data.get("description") + labels = data.get("labels") + parent = data.get("parent") + epic_link = data.get("epicLink") + idempotency_key = data.get("idempotencyKey") + + if not isinstance(project, str) or not _JIRA_PROJECT_KEY_RE.fullmatch(project): + _b().audit_log( + f"{operation}_rejected", + operation, + success=False, + details={"reason": "invalid project shape", **_session_jira_context()}, + ) + return make_error("Invalid project key", status_code=400) + + if not _b().is_project_allowed(project): + return _project_not_allowlisted_response( + event=f"{operation}_denied", + ticket=None, + project=project, + reason="project not allowlisted", + ) + + # issuetype: name or numeric id (refine decision-8). + if isinstance(issuetype, str): + if issuetype not in _JIRA_ALLOWED_ISSUETYPE_NAMES: + _b().audit_log( + f"{operation}_rejected", + operation, + success=False, + details={ + "reason": "unknown issuetype", + "issuetype": issuetype, + **_session_jira_context(), + }, + ) + return make_error( + f"Unknown issuetype name: {issuetype!r}", + status_code=400, + ) + issuetype_arg: dict[str, Any] | str = issuetype + elif isinstance(issuetype, dict): + if "name" in issuetype: + name = issuetype["name"] + if not isinstance(name, str) or name not in _JIRA_ALLOWED_ISSUETYPE_NAMES: + _b().audit_log( + f"{operation}_rejected", + operation, + success=False, + details={"reason": "unknown issuetype name", **_session_jira_context()}, + ) + return make_error(f"Unknown issuetype name: {name!r}", status_code=400) + issuetype_arg = {"name": name} + elif "id" in issuetype: + type_id = issuetype["id"] + if not isinstance(type_id, str) or not type_id.isdigit(): + return make_error("issuetype.id must be a numeric string", status_code=400) + issuetype_arg = {"id": type_id} + else: + return make_error("issuetype must include name or id", status_code=400) + else: + return make_error( + "issuetype must be a string, or a dict with 'name' or 'id'", + status_code=400, + ) + + if not isinstance(summary, str) or not summary.strip(): + return make_error("summary is required", status_code=400) + if len(summary) > _JIRA_SUMMARY_MAX_CHARS: + return make_error( + f"summary exceeds maximum length ({_JIRA_SUMMARY_MAX_CHARS} chars)", + status_code=400, + ) + + cleaned_description, err = _validate_jira_text_field( + description, field="description", max_chars=_JIRA_BODY_MAX_CHARS, allow_adf=True + ) + if err is not None: + return err + + cleaned_labels, err = _validate_jira_labels(labels, field="labels") + if err is not None: + return err + + if parent is not None and epic_link is not None: + _b().audit_log( + f"{operation}_rejected", + operation, + success=False, + details={"reason": "parent_and_epic_link", **_session_jira_context()}, + ) + return make_error( + "parent and epicLink are mutually exclusive", + status_code=400, + ) + + if parent is not None: + if not isinstance(parent, str) or not _JIRA_TICKET_KEY_RE.fullmatch(parent): + return make_error("Invalid parent ticket key", status_code=400) + # Cross-project parent rejection (refine decision-17). + parent_project = extract_project_key(parent) + if parent_project != project: + _b().audit_log( + f"{operation}_rejected", + operation, + success=False, + details={ + "reason": "cross_project_parent", + "project": project, + "parent_project": parent_project, + **_session_jira_context(), + }, + ) + return make_error( + "parent.key project must match the new ticket's project", + status_code=400, + details={"project": project, "parent_project": parent_project}, + ) + + if epic_link is not None: + if not isinstance(epic_link, str) or not _JIRA_TICKET_KEY_RE.fullmatch(epic_link): + return make_error("Invalid epicLink ticket key", status_code=400) + # epicLink writes to the same Atlassian field as `parent` when the + # site uses next-gen / company-managed projects (default + # `epic_link_field == "parent"`). That makes `epicLink` a literal + # alias for `parent` at the wire level, so it MUST inherit the same + # allowlist + cross-project policy as `parent` (decision-9, decision-17). + # Otherwise an agent in an allowlisted project could parent a new + # ticket under an epic in a non-allowlisted project just by routing + # through the `epicLink` shorthand instead of `parent`. + epic_project = extract_project_key(epic_link) + if not _b().is_project_allowed(epic_project): + return _project_not_allowlisted_response( + event=f"{operation}_denied", + ticket=epic_link, + project=epic_project, + reason="epicLink project not allowlisted", + ) + if epic_project != project: + _b().audit_log( + f"{operation}_rejected", + operation, + success=False, + details={ + "reason": "cross_project_epic_link", + "project": project, + "epic_project": epic_project, + **_session_jira_context(), + }, + ) + return make_error( + "epicLink project must match the new ticket's project", + status_code=400, + details={"project": project, "epic_project": epic_project}, + ) + + if idempotency_key is not None and not isinstance(idempotency_key, str): + return make_error("idempotencyKey must be a string", status_code=400) + + try: + status_code, body_json, cache_hit = ( + _b() + .get_jira_client() + .create_issue( + project_key=project, + issuetype=issuetype_arg, + summary=summary, + description=cleaned_description, + labels=cleaned_labels, + parent=parent, + epic_link=epic_link, + epic_link_field=_b().jira_epic_link_field(), + idempotency_key=idempotency_key if isinstance(idempotency_key, str) else None, + ) + ) + except JiraCredentialsUnavailable as exc: + return _jira_not_configured_error(exc) + except JiraUpstreamError as exc: + _b().audit_log( + f"{operation}_upstream_error", + operation, + success=False, + details={ + "project": project, + "upstream_status": exc.status_code, + **_jira_write_audit_meta(data), + **_session_jira_context(), + }, + ) + return _jira_error_from_upstream(exc) + + new_key = body_json.get("key") if isinstance(body_json, dict) else None + new_id = body_json.get("id") if isinstance(body_json, dict) else None + self_url = body_json.get("self") if isinstance(body_json, dict) else None + browse_url: str | None = None + if isinstance(self_url, str) and "/rest/api/" in self_url and isinstance(new_key, str): + # Trim the trailing /rest/api/3/issue/ to recover the site root, + # then append /browse/. This mirrors what Atlassian shows in + # its UI links. + site = self_url.split("/rest/api/", 1)[0] + browse_url = f"{site}/browse/{new_key}" + + # Match the doc's audit grammar: rejection events use ``_rejected`` / + # ``_denied`` / ``_upstream_error`` suffixes, so successful writes use + # ``_ok`` (reviewer_code_holistic cycle 1 finding #3, #1924). + _b().audit_log( + f"{operation}_ok", + operation, + success=True, + details={ + "project": project, + "ticket": new_key, + "upstream_status": status_code, + "idempotency_key_present": bool(idempotency_key), + "idempotency_hit": cache_hit, + **_jira_write_audit_meta(data), + **_session_jira_context(), + }, + ) + + envelope: dict[str, Any] = { + "status": "created", + "key": new_key, + "id": new_id, + "browse_url": browse_url, + } + return make_success("Jira ticket created", envelope) + + +def jira_ticket_edit() -> tuple[Response, int] | Response: + """Edit a Jira issue via ``PUT /rest/api/3/issue/{key}``. + + Request body:: + + {"ticket": "ENG-1", + "summary": "..." | null, + "description": "..." | | null, + "labels": [...] | null, # replace mode + "addLabels": [...] | null, # incremental mode + "removeLabels": [...] | null, + "notifyUsers": false | true} # default: false + + Replace-mode (``labels``) and incremental-mode + (``addLabels``/``removeLabels``) are mutually exclusive. + """ + operation = "jira_ticket_edit" + data = request.get_json(silent=True) or {} + + if not isinstance(data, dict): + return make_error("body must be a JSON object", status_code=400) + + err = _validate_jira_write_keys(data, _JIRA_EDIT_ALLOWED_KEYS, operation) + if err is not None: + return err + + ticket = data.get("ticket") + if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): + _b().audit_log( + f"{operation}_rejected", + operation, + success=False, + details={"reason": "invalid ticket shape", **_session_jira_context()}, + ) + return make_error("Invalid ticket key", status_code=400) + + project = extract_project_key(ticket) + if not _b().is_project_allowed(project): + return _project_not_allowlisted_response( + event=f"{operation}_denied", + ticket=ticket, + project=project, + reason="project not allowlisted", + ) + + summary = data.get("summary") + description = data.get("description") + labels = data.get("labels") + add_labels = data.get("addLabels") + remove_labels = data.get("removeLabels") + notify_users = data.get("notifyUsers", False) + + if summary is not None: + if not isinstance(summary, str): + return make_error("summary must be a string", status_code=400) + if len(summary) > _JIRA_SUMMARY_MAX_CHARS: + return make_error( + f"summary exceeds maximum length ({_JIRA_SUMMARY_MAX_CHARS} chars)", + status_code=400, + ) + + cleaned_description, err = _validate_jira_text_field( + description, field="description", max_chars=_JIRA_BODY_MAX_CHARS, allow_adf=True + ) + if err is not None: + return err + + has_replace = labels is not None + has_incremental = (add_labels is not None) or (remove_labels is not None) + if has_replace and has_incremental: + _b().audit_log( + f"{operation}_rejected", + operation, + success=False, + details={"reason": "mixed_label_modes", **_session_jira_context()}, + ) + return make_error( + "labels and addLabels/removeLabels are mutually exclusive", + status_code=400, + ) + + cleaned_labels, err = _validate_jira_labels(labels, field="labels") + if err is not None: + return err + cleaned_add, err = _validate_jira_labels(add_labels, field="addLabels") + if err is not None: + return err + cleaned_remove, err = _validate_jira_labels(remove_labels, field="removeLabels") + if err is not None: + return err + + if not isinstance(notify_users, bool): + return make_error("notifyUsers must be a boolean", status_code=400) + + # Require at least one mutating field to avoid no-op edits hitting upstream. + if ( + summary is None + and cleaned_description is None + and cleaned_labels is None + and cleaned_add is None + and cleaned_remove is None + ): + return make_error( + "edit requires at least one of summary/description/labels/addLabels/removeLabels", + status_code=400, + ) + + try: + _b().get_jira_client().edit_issue( + key=ticket, + summary=summary, + description=cleaned_description, + labels=cleaned_labels, + add_labels=cleaned_add, + remove_labels=cleaned_remove, + notify_users=notify_users, + ) + except ValueError as exc: + # Defence in depth — the route already rejected mixed modes. + return make_error(str(exc), status_code=400) + except JiraCredentialsUnavailable as exc: + return _jira_not_configured_error(exc) + except JiraUpstreamError as exc: + _b().audit_log( + f"{operation}_upstream_error", + operation, + success=False, + details={ + "ticket": ticket, + "project": project, + "upstream_status": exc.status_code, + **_jira_write_audit_meta(data), + **_session_jira_context(), + }, + ) + return _jira_error_from_upstream(exc) + + _b().audit_log( + f"{operation}_ok", + operation, + success=True, + details={ + "ticket": ticket, + "project": project, + "notify_users": notify_users, + # editIssue does not consult the idempotency cache (Atlassian + # PUT is naturally idempotent), but the field is included here + # for grammar parity with the create / comment / link routes. + "idempotency_key_present": False, + "idempotency_hit": False, + **_jira_write_audit_meta(data), + **_session_jira_context(), + }, + ) + return make_success("Jira ticket updated", {"status": "updated", "key": ticket}) + + +def jira_ticket_comment_add() -> tuple[Response, int] | Response: + """Add a comment to a Jira issue. + + Request body:: + + {"ticket": "ENG-1", + "body": "..." | , + "idempotencyKey": "..." | null} + + Visibility (role/group restriction) is rejected — v1 does not expose + that knob (refine decision-6). Body content is **never** logged. + """ + operation = "jira_ticket_comment_add" + data = request.get_json(silent=True) or {} + + if not isinstance(data, dict): + return make_error("body must be a JSON object", status_code=400) + + if "visibility" in data: + return make_error( + "comment visibility is not supported in v1", + status_code=400, + ) + + err = _validate_jira_write_keys(data, _JIRA_COMMENT_ALLOWED_KEYS, operation) + if err is not None: + return err + + ticket = data.get("ticket") + body = data.get("body") + idempotency_key = data.get("idempotencyKey") + + if not isinstance(ticket, str) or not _JIRA_TICKET_KEY_RE.fullmatch(ticket): + _b().audit_log( + f"{operation}_rejected", + operation, + success=False, + details={"reason": "invalid ticket shape", **_session_jira_context()}, + ) + return make_error("Invalid ticket key", status_code=400) + + project = extract_project_key(ticket) + if not _b().is_project_allowed(project): + return _project_not_allowlisted_response( + event=f"{operation}_denied", + ticket=ticket, + project=project, + reason="project not allowlisted", + ) + + cleaned_body, err = _validate_jira_text_field( + body, field="body", max_chars=_JIRA_BODY_MAX_CHARS, allow_adf=True + ) + if err is not None: + return err + if cleaned_body is None: + return make_error("body is required", status_code=400) + + if idempotency_key is not None and not isinstance(idempotency_key, str): + return make_error("idempotencyKey must be a string", status_code=400) + + try: + _status, comment_json, cache_hit = ( + _b() + .get_jira_client() + .add_comment( + key=ticket, + body=cleaned_body, + idempotency_key=idempotency_key if isinstance(idempotency_key, str) else None, + ) + ) + except JiraCredentialsUnavailable as exc: + return _jira_not_configured_error(exc) + except JiraUpstreamError as exc: + _b().audit_log( + f"{operation}_upstream_error", + operation, + success=False, + details={ + "ticket": ticket, + "project": project, + "upstream_status": exc.status_code, + # Note: _jira_write_audit_meta intentionally avoids body content; + # we still record body_length / body_kind here. + **_jira_write_audit_meta(data), + **_session_jira_context(), + }, + ) + return _jira_error_from_upstream(exc) + + _b().audit_log( + f"{operation}_ok", + operation, + success=True, + details={ + "ticket": ticket, + "project": project, + "idempotency_key_present": bool(idempotency_key), + "idempotency_hit": cache_hit, + **_jira_write_audit_meta(data), + **_session_jira_context(), + }, + ) + return make_success("Jira comment added", comment_json) + + +def jira_issue_link_create() -> tuple[Response, int] | Response: + """Create an issue link between two tickets. + + Request body:: + + {"type": "Blocks", + "inwardIssue": "ENG-1", + "outwardIssue": "ENG-2", + "comment": "..." | | null, + "idempotencyKey": "..." | null} + + Both tickets' projects must be in the allowlist (refine decision-9). + Atlassian does **not** dedupe identical triples, so the gateway uses + its idempotency cache (decision-28) when ``idempotencyKey`` is set. + """ + operation = "jira_issue_link_create" + data = request.get_json(silent=True) or {} + + if not isinstance(data, dict): + return make_error("body must be a JSON object", status_code=400) + + err = _validate_jira_write_keys(data, _JIRA_LINK_ALLOWED_KEYS, operation) + if err is not None: + return err + + link_type = data.get("type") + inward = data.get("inwardIssue") + outward = data.get("outwardIssue") + comment = data.get("comment") + idempotency_key = data.get("idempotencyKey") + + if not isinstance(link_type, str) or not link_type: + return make_error("type is required", status_code=400) + if not _b().jira_link_type_allowed(link_type): + _b().audit_log( + f"{operation}_rejected", + operation, + success=False, + details={ + "reason": "link_type_not_allowlisted", + "link_type": link_type, + **_session_jira_context(), + }, + ) + return make_error( + f"Link type {link_type!r} not in allowlist", + status_code=400, + details={"link_type": link_type}, + ) + + if not isinstance(inward, str) or not _JIRA_TICKET_KEY_RE.fullmatch(inward): + return make_error("inwardIssue must be a Jira ticket key", status_code=400) + if not isinstance(outward, str) or not _JIRA_TICKET_KEY_RE.fullmatch(outward): + return make_error("outwardIssue must be a Jira ticket key", status_code=400) + + inward_project = extract_project_key(inward) + outward_project = extract_project_key(outward) + for proj, ticket in ((inward_project, inward), (outward_project, outward)): + if not _b().is_project_allowed(proj): + return _project_not_allowlisted_response( + event=f"{operation}_denied", + ticket=ticket, + project=proj, + reason="project not allowlisted", + ) + + cleaned_comment, err = _validate_jira_text_field( + comment, field="comment", max_chars=_JIRA_BODY_MAX_CHARS, allow_adf=True + ) + if err is not None: + return err + + if idempotency_key is not None and not isinstance(idempotency_key, str): + return make_error("idempotencyKey must be a string", status_code=400) + + try: + _status, _link_json, cache_hit = ( + _b() + .get_jira_client() + .create_issue_link( + link_type=link_type, + inward_key=inward, + outward_key=outward, + comment=cleaned_comment, + idempotency_key=idempotency_key if isinstance(idempotency_key, str) else None, + ) + ) + except JiraCredentialsUnavailable as exc: + return _jira_not_configured_error(exc) + except JiraUpstreamError as exc: + _b().audit_log( + f"{operation}_upstream_error", + operation, + success=False, + details={ + "inwardIssue": inward, + "outwardIssue": outward, + "type": link_type, + "upstream_status": exc.status_code, + **_jira_write_audit_meta(data), + **_session_jira_context(), + }, + ) + return _jira_error_from_upstream(exc) + + _b().audit_log( + f"{operation}_ok", + operation, + success=True, + details={ + "inwardIssue": inward, + "outwardIssue": outward, + "type": link_type, + "inward_project": inward_project, + "outward_project": outward_project, + "idempotency_key_present": bool(idempotency_key), + "idempotency_hit": cache_hit, + **_jira_write_audit_meta(data), + **_session_jira_context(), + }, + ) + return make_success( + "Jira issue link created", + { + "status": "created", + "inwardIssue": inward, + "outwardIssue": outward, + "type": link_type, + }, + ) diff --git a/gateway/gateway/_proxy.py b/gateway/gateway/_proxy.py new file mode 100644 index 0000000000..7e62660754 --- /dev/null +++ b/gateway/gateway/_proxy.py @@ -0,0 +1,946 @@ +"""Gateway proxy cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import json +from typing import Any, NamedTuple + +import httpx +from egg_session_placeholder import from_placeholder as _session_token_from_placeholder +from flask import Response, jsonify, request, stream_with_context + +try: + from ..routing_policy import ( + RouteHop, + ) + from ..upstream_registry import ( + UnknownUpstreamError, + ) +except ImportError: # flat/container import mode + from routing_policy import ( # type: ignore[no-redef, import-untyped] + RouteHop, + ) + from upstream_registry import ( # type: ignore[no-redef, import-untyped] + UnknownUpstreamError, + ) + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + + +def _get_forwarded_headers(request_headers: Any) -> dict[str, str]: + """Forward all headers except blocked ones (blocklist approach).""" + return {k: v for k, v in request_headers if k.lower() not in _b().ANTHROPIC_BLOCKED_HEADERS} + + +def _filter_response_headers(headers: Any) -> dict[str, str]: + """Filter response headers for passthrough.""" + # Preserve important headers like x-request-id for debugging + skip = {"content-encoding", "transfer-encoding", "connection"} + return {k: v for k, v in headers.items() if k.lower() not in skip} + + +def _inject_upstream_credentials( + headers: dict[str, str], + upstream: str = "anthropic", +) -> tuple[dict[str, str], tuple[Any, int] | None]: + """ + Inject upstream credentials into headers. + + Dispatches per-upstream so the gateway can carry both Anthropic and + LiteLLM credentials side-by-side (issue #2769 cq-7). For the Anthropic + upstream this is byte-identical to the legacy + ``_inject_anthropic_credentials`` helper — same OAuth/API-key precedence, + same 401 error shape on missing credentials, same client-supplied auth + fall-through. The LiteLLM upstream uses ``x-api-key`` only (no OAuth + path) and has no client-supplied-auth fall-through because Claude Code + never carries a LiteLLM master key. + + An upstream the registry does not serve is rejected with a 502 — it + is never silently treated as Anthropic. + + Args: + headers: Mutable header dict — credential is appended in place. + upstream: ``"anthropic"`` (default — back-compat) or ``"litellm"``. + + Returns: + (headers, None) on success + (headers, error_response_tuple) on failure - caller should return this + """ + # Refuse to silently treat an unknown upstream as Anthropic. Falling + # through to the Anthropic branch produced an observable error-code + # inconsistency — 401 vs 502 for the same invalid input depending on + # unrelated Anthropic-credential state. An unregistered upstream now + # fails closed with a 502, matching the proxy routes' own + # UnknownUpstreamError handling (issue #2769 review). + if not _b().get_upstream_registry().is_known(upstream): + logger.warning( + "Unknown upstream for credential injection, refusing request", + upstream=upstream, + ) + return headers, ( + jsonify( + { + "error": { + "type": "api_error", + "message": f"Unknown upstream '{upstream}'", + } + } + ), + 502, + ) + + if upstream == "litellm": + cred = _b().get_litellm_credentials_manager().get_credential() + if cred: + headers[cred.header_name] = cred.header_value + return headers, None + logger.warning( + "No LiteLLM master key available for proxy request", + upstream=upstream, + ) + return headers, ( + jsonify( + { + "error": { + "type": "authentication_error", + "message": "No LiteLLM credentials available", + } + } + ), + 401, + ) + + # Default: anthropic upstream — preserves the legacy behavior verbatim. + credentials_manager = _b().get_credentials_manager() + cred = credentials_manager.get_credential() + + if cred: + # Credential includes header_name (x-api-key or Authorization) + # and header_value (raw key or "Bearer ") + headers[cred.header_name] = cred.header_value + return headers, None + + # No gateway-managed credentials - check if client sent auth + # This allows OAuth mode where Claude Code manages its own tokens + client_auth = headers.get("Authorization") + client_api_key = headers.get("x-api-key") + if client_auth or client_api_key: + return headers, None + + logger.warning( + "No Anthropic credentials available for proxy request", + has_gateway_cred=False, + has_client_auth=bool(client_auth), + has_client_api_key=bool(client_api_key), + ) + return headers, ( + jsonify( + { + "error": { + "type": "authentication_error", + "message": "No Anthropic credentials available", + } + } + ), + 401, + ) + + +def _inject_anthropic_credentials( + headers: dict[str, str], +) -> tuple[dict[str, str], tuple[Any, int] | None]: + """Back-compat alias delegating to the upstream-aware injector. + + Kept so external test mocks targeting ``_inject_anthropic_credentials`` + continue to work. New code paths should call + ``_inject_upstream_credentials(headers, upstream)`` directly. + """ + return _inject_upstream_credentials(headers, upstream="anthropic") + + +def _extract_wire_model(request_body: bytes) -> str | None: + """Return the request body's ``"model"`` field, or ``None`` on parse miss.""" + try: + model = json.loads(request_body).get("model") + except json.JSONDecodeError, TypeError, AttributeError: + return None + return model if isinstance(model, str) else None + + +def _rewrite_upstream_model(request_body: bytes, model: str) -> bytes: + """Set ``body["model"] = model`` and re-encode; original bytes on parse miss. + + A narrowly-scoped reintroduction of the helper #2832 retired. #2832 + removed the *unconditional* LiteLLM-path rewrite (Claude Code now sends + the wire model directly); this version fires ONLY when a routing-policy + hop names an explicit target model, so the no-policy path is + byte-identical — the body is never touched unless a switchover or + fallback hop specifies a ``model``. + """ + try: + body = json.loads(request_body) + except json.JSONDecodeError, TypeError: + return request_body + if not isinstance(body, dict): + return request_body + body["model"] = model + return json.dumps(body).encode() + + +def _resolve_route_chain( + session_upstream: str, + request_body: bytes, +) -> tuple[list[RouteHop], Any]: + """Resolve a request into an ordered ``[RouteHop, ...]`` chain + triggers. + + Hop 0 is the *initial* route: a proactive ``switchover`` remap for the + wire model if one is configured, else the spawn-time ``session_upstream`` + (no model rewrite — byte-identical to today). Hops 1..N are the reactive + fallback chain for the wire model, in order. With an empty policy the + chain is a single hop on ``session_upstream`` and behavior is + byte-identical to the pre-#2987 path. + """ + policy = _b().get_routing_policy_manager().get_policy() + wire_model = _b()._extract_wire_model(request_body) + + switch = policy.switchover_for(wire_model) + initial = switch if switch is not None else RouteHop(upstream=session_upstream, model=None) + chain = [initial, *policy.fallback_chain_for(wire_model)] + return chain, policy.triggers + + +class _PreparedHop(NamedTuple): + """A hop ready to send: resolved client, freshly-injected headers, body.""" + + client: Any + headers: dict[str, str] + body: bytes + + +class _HopPrepError(Exception): + """Raised by ``_prepare_hop`` when a hop cannot be prepared. + + Carries the ``(Response, status)`` tuple the proxy route should return if + this is the last hop (the caller advances to a fallback instead when one + exists). Modeling the failure as an exception — rather than an optional + field in the return tuple — lets the success path be a non-optional + ``_PreparedHop`` that type-checks cleanly at the call sites. + """ + + def __init__(self, response: tuple[Any, int]) -> None: + super().__init__("hop preparation failed") + self.response = response + + +def _sanitize_attribution_value(value: str) -> str: + """Constrain a session field to a safe HTTP header value. + + The values are orchestrator-authoritative (set via the launcher-secret + ``register_session``), so this is belt-and-braces: drop anything outside + printable ASCII (CR/LF would otherwise allow header injection) and cap + the length so a pathological value cannot bloat every upstream request. + """ + return "".join(ch for ch in value if 32 <= ord(ch) < 127)[:256] + + +def _with_attribution_headers(headers: dict[str, str], session: Any) -> dict[str, str]: + """Stamp gateway-authoritative ``x-egg-*`` attribution onto a non-Anthropic hop. + + The egg-litellm ``cost_callback`` keys its per-session cost/cache log + lines on ``x-claude-code-session-id``, which maps to a role only by hand + cross-referencing agent completion logs (issue #3175). The gateway + resolves the full ``Session`` — ``pipeline_id`` / ``agent_role`` / + ``phase`` — on every ``/v1/messages`` call anyway, so it stamps them here + and the callback logs spend per role directly. + + Any client-supplied ``x-egg-*`` header is dropped first: the sandbox + controls its own request headers (e.g. via ``ANTHROPIC_CUSTOM_HEADERS``), + so agent-supplied values are untrusted and must never masquerade as + attribution. Applied only to non-Anthropic hops — the Claude path stays + byte-identical. + """ + headers = {k: v for k, v in headers.items() if not k.lower().startswith("x-egg-")} + if session is None: + return headers + for header, value in ( + ("x-egg-pipeline-id", session.pipeline_id), + ("x-egg-agent-role", session.agent_role), + ("x-egg-phase", session.phase), + ): + if value: + sanitized = _b()._sanitize_attribution_value(str(value)) + # A value of only control chars sanitizes to "" — don't stamp an + # empty-valued header (the callback would coerce it to None anyway). + if sanitized: + headers[header] = sanitized + return headers + + +def _prepare_hop( + hop: RouteHop, + request_headers: Any, + request_body: bytes, + session: Any = None, +) -> _PreparedHop: + """Build the (client, headers, body) for one hop. + + Headers are rebuilt with ``_get_forwarded_headers`` from the *original* + request headers on every call, then this hop's credential is injected — + so a fallback hop never inherits the previous upstream's auth header. + Non-Anthropic hops additionally carry ``x-egg-*`` attribution headers + derived from ``session`` (issue #3175); see ``_with_attribution_headers``. + Raises ``_HopPrepError`` (carrying the error response) on a credential or + unknown-upstream failure for this hop; the caller decides whether to + advance to a fallback or surface it. + """ + headers = _get_forwarded_headers(request_headers) + headers, cred_error = _inject_upstream_credentials(headers, upstream=hop.upstream) + if cred_error: + raise _HopPrepError(cred_error) + if hop.upstream != "anthropic": + headers = _b()._with_attribution_headers(headers, session) + + if hop.upstream == "anthropic": + client = _b().get_anthropic_client() + else: + try: + client, _ = _b().get_upstream_registry().get(hop.upstream) + except UnknownUpstreamError: + logger.warning("Unknown upstream on routing hop, refusing", upstream=hop.upstream) + raise _HopPrepError( + ( + jsonify( + { + "error": { + "type": "api_error", + "message": f"Unknown upstream '{hop.upstream}'", + } + } + ), + 502, + ) + ) from None + + body = _b()._rewrite_upstream_model(request_body, hop.model) if hop.model else request_body + return _PreparedHop(client=client, headers=headers, body=body) + + +def _classify_route_status( + status: int, + triggers: Any, + same_hop_attempts: int, + is_last_hop: bool, +) -> str: + """Decide what to do with an upstream's status: retry the same hop, + advance to the next hop, or accept the response. + + Same-hop retry takes precedence while the budget remains (so a code that + is in *both* ``retry_same_on`` and ``advance_on`` retries first, then + escalates). ``advance`` only fires when a fallback hop exists. + """ + if status in triggers.retry_same_on and same_hop_attempts < triggers.retry_same_max: + return "retry_same" + if status in triggers.advance_on and not is_last_hop: + return "advance" + return "accept" + + +_UPSTREAM_TRANSPORT_ERRORS = ( + httpx.ReadError, + httpx.RemoteProtocolError, + httpx.ConnectError, + httpx.TimeoutException, +) + + +def _close_quietly(resp: Any) -> None: + """Close an httpx streaming response, swallowing errors. + + Used to release a discarded upstream connection when the routing loop + retries the same hop or advances to a fallback — a streaming response we + are not going to forward must be closed or its connection leaks. + """ + try: + resp.close() + except Exception: + pass + + +def _send_and_prime( + client: Any, + headers: dict[str, str], + body: bytes, +) -> tuple[Any, Any, bytes | None]: + """Send the upstream request and pre-fetch the first chunk. + + Returns ``(upstream_response, iterator, first_chunk)`` where + ``first_chunk`` is ``None`` if the upstream returned an empty body. + Raises a transport error (``httpx.ReadError`` / ``RemoteProtocolError`` / + ``ConnectError`` / ``TimeoutException``) if the connection fails during + ``send()`` or the first ``iter_bytes()`` call — callers use that signal + to retry the same hop or advance to a fallback (issues #1907, #2987). + """ + http_req = client.build_request("POST", "/v1/messages", headers=headers, content=body) + upstream_resp = client.send(http_req, stream=True) + try: + iterator = upstream_resp.iter_bytes() + try: + first = next(iterator) + except StopIteration: + first = None + return upstream_resp, iterator, first + except BaseException: + # Close the failed upstream so the caller's retry can open a fresh + # connection without leaking the old one. Broad catch ensures + # cleanup on *any* exception from iter_bytes() / next(). + try: + upstream_resp.close() + except Exception: + pass + raise + + +def _attempt_hop_streaming( + client: Any, + headers: dict[str, str], + body: bytes, + *, + container_id: str | None, +) -> tuple[Any, Any, bytes | None]: + """One streaming hop with the #1907 pre-stream transport-reset retry. + + Retries the *same* upstream once if the connection resets before any byte + is forwarded downstream, then raises on the second failure. The + cross-hop routing loop turns that raise into an advance-or-surface + decision (#2987). + """ + for attempt in range(2): + try: + return _send_and_prime(client, headers, body) + except _UPSTREAM_TRANSPORT_ERRORS as reset_err: + if attempt == 0 and isinstance(reset_err, (httpx.ReadError, httpx.RemoteProtocolError)): + logger.warning( + "Upstream connection reset before any byte was forwarded; " + "retrying same upstream once", + container_id=container_id, + error=str(reset_err), + ) + continue + # Connect/timeout failures (and the exhausted reset retry) are not + # retried in place — the routing loop advances to a fallback hop + # if one exists, else re-raises to the outer 502/504 handler. + raise + # Unreachable: ``range(2)`` always returns on success or raises on the + # second attempt. Present so the type checker sees no fall-through path. + raise AssertionError("unreachable") # pragma: no cover + + +BLOCKED_TOOLS_PRIVATE_MODE = {"web_search", "WebSearch", "web_fetch", "WebFetch"} + + +def _resolve_proxy_session( + request_headers: Any, + remote_addr: str | None, +) -> tuple[Any, tuple[Response, int] | None]: + """ + Resolve the session for a ``/v1/messages`` (or ``/count_tokens``) proxy + request. + + Order of resolution (issue #2829): + + 1. **Token-keyed.** Extract the session token from ``x-api-key`` / + ``Authorization`` if the value carries the egg placeholder + envelope. The orchestrator wraps the session token in this + placeholder so Claude Code's local OAuth-token format check + passes while the gateway can still identify the session. This + is the load-bearing path for agent traffic. + 2. **IP-keyed.** When the placeholder is absent, fall back to + source-IP lookup. Pod IPs are ephemeral in k8s so this is a + compat path for non-agent clients only (health probes, host + dev tools). The slice-1 "no session → anthropic" invariant for + non-agent probes is preserved. + + Defense-in-depth: when the placeholder IS present but the session + lookup misses, return a 502. Silently falling through to the + anthropic default would silently mis-route per-agent inference + (the routing bug) and disable private-mode web-tool filtering (the + filter-bypass bug). Both were invisible at runtime before the fix. + + Side effect: ``get_session`` delegates to ``validate_session``, + which calls ``session.extend_ttl`` on every successful lookup. The + proxy is therefore no longer a read-only consumer of the session — + each ``/v1/messages`` (or ``/count_tokens``) call bumps the + session's ``last_seen``, so active agent inference keeps the + session alive without a separate heartbeat. The legacy + ``get_session_by_ip`` fallback is still read-only. + + Returns ``(session_or_none, error_response_or_none)``. On error the + caller MUST return the error response; on success ``session`` may + be ``None`` for non-placeholder probes and the caller falls back + to the anthropic default. + """ + raw_auth = request_headers.get("x-api-key") or request_headers.get("Authorization") + placeholder_token = _session_token_from_placeholder(raw_auth) + session_manager = _b().get_session_manager() + + if placeholder_token: + session = session_manager.get_session(placeholder_token) + if session is None: + # ``validate_session`` (called via ``get_session``) already + # logs ``event_type=session_auth_failed`` with the token + # hash; emitting a second warning here would double-count + # any "auth failure rate" alert keyed off the first event. + # Caller's ``remote_addr`` shows up in standard request + # logs for correlation. + return None, ( + jsonify( + { + "error": { + "type": "api_error", + "message": "Unknown or expired session", + } + } + ), + 502, + ) + return session, None + + # Non-agent probe (no placeholder). Try IP-keyed lookup for + # backwards compatibility but failure here is non-fatal — the + # caller falls through to the anthropic default. + return session_manager.get_session_by_ip(remote_addr or ""), None + + +def _filter_blocked_tools(request_body: bytes, session_mode: str | None) -> bytes: + """ + Remove blocked tools from API request when in private mode. + + In private mode, WebSearch and WebFetch bypass container network controls + because they're processed by Anthropic's infrastructure. This creates a + data exfiltration risk where a compromised agent could encode sensitive + data in search queries. + + By filtering these tools at the gateway, we enforce the restriction at + the infrastructure level where the container cannot bypass it. + + Args: + request_body: Raw JSON request body + session_mode: The session's mode ("private" or "public"), or None + + Returns: + Modified request body with blocked tools removed (if in private mode), + or original body unchanged (if in public mode or on parse error) + """ + if session_mode != "private": + return request_body + + try: + body = json.loads(request_body) + if "tools" not in body: + return request_body + + original_tools = body["tools"] + filtered_tools = [ + t for t in original_tools if t.get("name") not in BLOCKED_TOOLS_PRIVATE_MODE + ] + + removed_count = len(original_tools) - len(filtered_tools) + if removed_count > 0: + removed_names = [ + t.get("name") for t in original_tools if t.get("name") in BLOCKED_TOOLS_PRIVATE_MODE + ] + logger.info( + "Filtered blocked tools in private mode", + removed_count=removed_count, + removed_tools=removed_names, + ) + body["tools"] = filtered_tools + return json.dumps(body).encode() + + except (json.JSONDecodeError, TypeError) as e: + logger.warning("Failed to parse request body for tool filtering", error=str(e)) + + return request_body + + +def _is_streaming_request(request_body: bytes) -> bool: + """ + Check if request body indicates streaming mode. + + Parses JSON properly to avoid false positives from byte string matching. + """ + try: + body_json = json.loads(request_body) + return body_json.get("stream", False) is True + except json.JSONDecodeError, TypeError: + return False + + +def proxy_anthropic_messages() -> tuple[Response, int] | Response: + """ + Proxy messages API with credential injection and streaming support. + + This endpoint allows Claude Code to use ANTHROPIC_BASE_URL to route + API traffic through the gateway for credential injection. + + Session lookup is token-keyed via a placeholder embedded in the + ``x-api-key`` header (issue #2829). The orchestrator wraps the + session token in ``sk-ant-oat01-PROXY-INJECTED-egg-session-`` + so Claude Code's local format check passes; the gateway extracts + the token and looks the session up. Non-agent probes (no + placeholder) keep the legacy IP-keyed compat path. + """ + session, lookup_error = _resolve_proxy_session(request.headers, request.remote_addr) + if lookup_error: + return lookup_error + session_mode = session.mode if session else None + container_id = session.container_id if session else None + # Resolve per-session upstream (issue #2769). With no session, default to + # "anthropic" so today's Claude path is byte-identical when an unrelated + # client probes /v1/messages without first registering a session. + upstream_name = session.upstream if session else "anthropic" + + request_body = request.get_data() + request_body = _filter_blocked_tools( + request_body, session_mode + ) # Remove web tools in private mode + # Per #2832, Claude Code on the LiteLLM path sends the upstream model + # name on the wire directly (via ANTHROPIC_CUSTOM_MODEL_OPTION). The + # gateway only rewrites ``"model"`` when a routing-policy hop names an + # explicit target (see ``_prepare_hop`` / ``_rewrite_upstream_model``). + is_streaming = _is_streaming_request(request_body) + + # Resolve the routing chain (issue #2987). Hop 0 is the proactive + # ``switchover`` remap for this wire model, or — with no switchover entry + # — the spawn-time ``session.upstream``. Hops 1..N are the reactive + # ``fallbacks`` chain for the wire model. With an empty routing policy the + # chain is a single hop on ``session.upstream`` and every step below is + # byte-identical to the pre-#2987 path. ``triggers`` decides which status + # codes retry the same upstream vs advance to the next hop; credentials + # are rebuilt per hop inside ``_prepare_hop`` so a fallback never carries + # the previous upstream's auth header. + chain, triggers = _resolve_route_chain(upstream_name, request_body) + # The upstream actually serving the request, for error/log context. The + # outer ``except`` handlers below read this so a fallback hop's failure + # is attributed to the hop that failed, not hop 0. + serving_upstream = chain[0].upstream + + try: + if is_streaming: + # Stream SSE response using httpx's send() with stream=True + # This gives us direct control over the response lifecycle. + # + # Resilience strategy (see #1907, extended for routing in #2987): + # (A) Pre-stream retry — if the upstream TCP connection resets + # before any byte has been yielded downstream, open a fresh + # upstream connection and retry the same hop once + # (``_attempt_hop_streaming``). The downstream SDK never + # sees the error. + # (B) Cross-hop fallback — if the primed upstream returns a + # trigger status (quota / opt-in 5xx) or a transport + # failure that survives (A), and a fallback hop exists, + # advance to it. All of this happens in the *pre-stream* + # window, before any byte is forwarded downstream. + # (C) Mid-stream synthetic error — once bytes have flowed, a + # reset can no longer fall back; emit a well-formed SSE + # ``event: error`` frame and close cleanly so the SDK fails + # gracefully instead of dying on a truncated socket. + # + # Full stream resumption is not attempted — Anthropic's API has + # no resume tokens, and the partial generation on the wire is + # orphaned on any mid-stream reset regardless. + upstream: Any = None + primed_iterator: Any = None + first_chunk: bytes | None = None + hop_idx = 0 + same_hop_attempts = 0 + while True: + hop = chain[hop_idx] + is_last_hop = hop_idx == len(chain) - 1 + serving_upstream = hop.upstream + try: + prepared = _prepare_hop(hop, request.headers, request_body, session=session) + except _HopPrepError as prep_err: + if is_last_hop: + return prep_err.response + logger.warning( + "Routing hop failed credential/upstream prep; advancing", + upstream=hop.upstream, + next_upstream=chain[hop_idx + 1].upstream, + ) + hop_idx += 1 + same_hop_attempts = 0 + continue + try: + upstream, primed_iterator, first_chunk = _attempt_hop_streaming( + prepared.client, prepared.headers, prepared.body, container_id=container_id + ) + except _UPSTREAM_TRANSPORT_ERRORS as hop_err: + if is_last_hop: + # Last hop — surface via the outer 502/504 handlers, + # preserving today's error contract. + raise + logger.warning( + "Routing hop transport failure; advancing to fallback", + upstream=hop.upstream, + next_upstream=chain[hop_idx + 1].upstream, + error=str(hop_err), + ) + hop_idx += 1 + same_hop_attempts = 0 + continue + + decision = _b()._classify_route_status( + upstream.status_code, triggers, same_hop_attempts, is_last_hop + ) + if decision == "retry_same": + same_hop_attempts += 1 + logger.warning( + "Upstream returned a retryable status; retrying same upstream", + upstream=hop.upstream, + status=upstream.status_code, + attempt=same_hop_attempts, + ) + _close_quietly(upstream) + continue + if decision == "advance": + logger.warning( + "Upstream returned a fallback-trigger status; advancing", + upstream=hop.upstream, + status=upstream.status_code, + next_upstream=chain[hop_idx + 1].upstream, + ) + _close_quietly(upstream) + hop_idx += 1 + same_hop_attempts = 0 + continue + break # accept this hop's response + + response_headers = _filter_response_headers(upstream.headers) + # Forward actual Content-Type from upstream (usually text/event-stream) + content_type = upstream.headers.get("content-type", "text/event-stream") + + def generate() -> Any: + try: + if first_chunk is not None: + yield first_chunk + yield from primed_iterator + except (httpx.ReadError, httpx.RemoteProtocolError) as mid_err: + # Mid-stream reset: emit a synthetic SSE `error` frame so + # the downstream SDK treats this as a clean API error + # instead of a truncated socket. The frame shape matches + # Anthropic's documented error event. + logger.warning( + "Upstream stream reset mid-response; emitting synthetic SSE error frame", + upstream=serving_upstream, + container_id=container_id, + error=str(mid_err), + ) + error_payload = { + "type": "error", + "error": { + "type": "api_error", + "message": "upstream connection reset", + }, + } + error_frame = ( + b"event: error\ndata: " + + json.dumps(error_payload).encode("utf-8") + + b"\n\n" + ) + yield error_frame + finally: + upstream.close() + + return Response( + stream_with_context(generate()), + status=upstream.status_code, + headers=response_headers, + content_type=content_type, + ) + else: + # Non-streaming: walk the same routing chain without priming. + # Status-based retry/advance applies; a transport failure on a + # non-last hop advances (else surfaces via the outer handlers, + # preserving today's 502/504 contract). + response: Any = None + hop_idx = 0 + same_hop_attempts = 0 + while True: + hop = chain[hop_idx] + is_last_hop = hop_idx == len(chain) - 1 + serving_upstream = hop.upstream + try: + prepared = _prepare_hop(hop, request.headers, request_body, session=session) + except _HopPrepError as prep_err: + if is_last_hop: + return prep_err.response + hop_idx += 1 + same_hop_attempts = 0 + continue + try: + response = prepared.client.post( + "/v1/messages", headers=prepared.headers, content=prepared.body + ) + except _UPSTREAM_TRANSPORT_ERRORS: + if is_last_hop: + raise + hop_idx += 1 + same_hop_attempts = 0 + continue + + decision = _b()._classify_route_status( + response.status_code, triggers, same_hop_attempts, is_last_hop + ) + if decision == "retry_same": + same_hop_attempts += 1 + continue + if decision == "advance": + hop_idx += 1 + same_hop_attempts = 0 + continue + break # accept + + return Response( + response.content, + status=response.status_code, + headers=_filter_response_headers(response.headers), + ) + + except httpx.ConnectError as e: + logger.error("Upstream connection failed", upstream=serving_upstream, error=str(e)) + return jsonify( + { + "error": { + "type": "api_error", + "message": f"Failed to connect to {serving_upstream} upstream: {e}", + } + } + ), 502 + + except httpx.TimeoutException as e: + logger.error("Upstream request timed out", upstream=serving_upstream, error=str(e)) + return jsonify( + { + "error": { + "type": "api_error", + "message": f"{serving_upstream} upstream request timed out: {e}", + } + } + ), 504 + + except Exception as e: + logger.exception("Upstream proxy error", upstream=serving_upstream) + return jsonify( + { + "error": { + "type": "api_error", + "message": f"{serving_upstream} upstream proxy error: {e}", + } + } + ), 502 + + +def proxy_count_tokens() -> tuple[Response, int] | Response: + """ + Proxy token counting API (non-streaming). + + This endpoint allows Claude Code to use ANTHROPIC_BASE_URL to route + token counting requests through the gateway. + """ + # Mirror the per-session lookup used by proxy_anthropic_messages so + # count_tokens and messages always agree on which backend serves a + # given agent (issues #2769, #2829). + session, lookup_error = _resolve_proxy_session(request.headers, request.remote_addr) + if lookup_error: + return lookup_error + upstream_name = session.upstream if session else "anthropic" + + count_tokens_body = request.get_data() + + # Honor the proactive ``switchover`` remap so token-counting hits the + # same backend (and model) that messages will use (issue #2987). The + # reactive fallback chain is intentionally NOT walked here — token + # counting is an informational pre-flight, not load-bearing inference, + # so a quota miss surfaces rather than escalating. We take only hop 0 of + # the resolved chain; ``_prepare_hop`` rebuilds clean headers + applies + # the optional model rewrite for that hop. + chain, _triggers = _resolve_route_chain(upstream_name, count_tokens_body) + initial_hop = chain[0] + serving_upstream = initial_hop.upstream + try: + prepared = _prepare_hop(initial_hop, request.headers, count_tokens_body, session=session) + except _HopPrepError as prep_err: + return prep_err.response + + try: + response = prepared.client.post( + "/v1/messages/count_tokens", + headers=prepared.headers, + content=prepared.body, + ) + return Response( + response.content, + status=response.status_code, + headers=_filter_response_headers(response.headers), + ) + + except httpx.ConnectError as e: + logger.error("Upstream connection failed", upstream=serving_upstream, error=str(e)) + return jsonify( + { + "error": { + "type": "api_error", + "message": f"Failed to connect to {serving_upstream} upstream: {e}", + } + } + ), 502 + + except httpx.TimeoutException as e: + logger.error("Upstream request timed out", upstream=serving_upstream, error=str(e)) + return jsonify( + { + "error": { + "type": "api_error", + "message": f"{serving_upstream} upstream request timed out: {e}", + } + } + ), 504 + + except Exception as e: + logger.exception("Upstream proxy error", upstream=serving_upstream) + return jsonify( + { + "error": { + "type": "api_error", + "message": f"{serving_upstream} upstream proxy error: {e}", + } + } + ), 502 diff --git a/gateway/gateway/_server.py b/gateway/gateway/_server.py new file mode 100644 index 0000000000..64fb5a8d1e --- /dev/null +++ b/gateway/gateway/_server.py @@ -0,0 +1,413 @@ +"""Gateway server cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import argparse +import json +import os +import signal +import socket +import sys +import threading +import time +from typing import Any + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + + +def _run_health_server(host: str, port: int) -> None: + """Run a dedicated lightweight HTTP server for health checks. + + This server runs on a separate port from the main Waitress thread pool, + ensuring health checks are never blocked by long-running API requests + (e.g., synchronous git operations holding Waitress threads). + """ + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + class HealthHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + if self.path != "/api/v1/health": + self.send_response(404) + self.end_headers() + return + + # Lightweight health check for Docker liveness probes. + # Note: is_token_valid() can block during token refresh (up to 30s + # synchronous HTTP call to GitHub). ThreadingHTTPServer ensures a + # slow refresh doesn't block concurrent health check requests. + # The full health endpoint on the main port still does + # orchestrator/squid process checks for detailed diagnostics. + try: + github = _b().get_github_client() + token_valid = github.is_token_valid() + except Exception: + token_valid = False + + try: + _b().get_launcher_secret() + launcher_ok = True + except Exception: + launcher_ok = False + + # Quick squid port check + squid_listening = False + try: + with socket.create_connection(("127.0.0.1", 3129), timeout=2): + squid_listening = True + except OSError: + pass + + is_healthy = token_valid and launcher_ok and squid_listening + body = json.dumps( + { + "status": "healthy" if is_healthy else "degraded", + "github_token_valid": token_valid, + "auth_configured": launcher_ok, + "squid_proxy": {"listening": squid_listening}, + "service": "gateway", + } + ).encode() + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: + # Suppress default stderr logging for health checks + pass + + server = ThreadingHTTPServer((host, port), HealthHandler) + server.serve_forever() + + +def main() -> None: + """Run the gateway server.""" + # Safety check: refuse to run as root to prevent permission issues + # When the gateway runs as root, git objects are created with root:root ownership, + # which breaks git operations on the host (permission denied on .git/objects). + if os.getuid() == 0: + print( + "ERROR: gateway must not run as root.\n" + "\n" + "Running as root causes git objects to be created with root:root ownership,\n" + "which breaks git operations on the host with 'permission denied' errors.\n" + "\n" + "To fix this:\n" + " 1. Check the service file path in gateway.service\n" + " 2. Ensure the gateway is started via 'egg' or 'bin/egg-deploy up'\n" + " 3. Restart the gateway and try again\n" + " 4. Verify the gateway is running as your user: ps aux | grep gateway\n" + "\n" + "If .git/objects already has root-owned files, fix with:\n" + " sudo chown -R $(id -u):$(id -g) ~/repos/*/.git", + file=sys.stderr, + ) + sys.exit(1) + + parser = argparse.ArgumentParser(description="Gateway Sidecar REST API") + parser.add_argument( + "--host", + default=_b().DEFAULT_HOST, + help=f"Host to listen on (default: {_b().DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=_b().DEFAULT_PORT, + help=f"Port to listen on (default: {_b().DEFAULT_PORT})", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug mode", + ) + parser.add_argument( + "--threads", + type=int, + default=_b().DEFAULT_THREADS, + help=f"Waitress thread pool size (default: {_b().DEFAULT_THREADS})", + ) + parser.add_argument( + "--health-port", + type=int, + default=_b().HEALTH_CHECK_PORT, + help=f"Dedicated health check port (default: {_b().HEALTH_CHECK_PORT})", + ) + + args = parser.parse_args() + + # Initialize token refresher for in-memory token management. + # Retry with backoff on transient failures (e.g. DNS not available at startup). + # Without a GitHub token the gateway can't serve its purpose, so exit if + # initialization never succeeds. + token_init_timeout = int(os.environ.get("EGG_TOKEN_INIT_TIMEOUT", "120")) + try: + from token_refresher import ( + initialize_token_refresher, + is_token_refresher_permanently_failed, + ) + + refresher = None + start_time = time.time() + attempt = 0 + while True: + attempt += 1 + refresher = initialize_token_refresher() + if refresher: + logger.info("Token refresher initialized (in-memory token refresh enabled)") + break + + # Permanent failures (missing credentials/key file) won't resolve + # on retry — exit immediately instead of waiting for the timeout. + if is_token_refresher_permanently_failed(): + logger.warning("Token refresher not configured - GitHub operations will fail") + break + + elapsed = time.time() - start_time + if elapsed >= token_init_timeout: + logger.error( + "Token refresher failed to initialize after timeout — exiting", + timeout_seconds=token_init_timeout, + attempts=attempt, + ) + sys.exit(1) + + backoff = min(5 * (2 ** (attempt - 1)), 30) + remaining = token_init_timeout - elapsed + wait = min(backoff, remaining) + logger.warning( + "Token refresher not ready, retrying", + attempt=attempt, + retry_in_seconds=wait, + elapsed_seconds=round(elapsed, 1), + timeout_seconds=token_init_timeout, + ) + time.sleep(wait) + except ImportError: + logger.error("Token refresher module not available - GitHub operations will fail") + sys.exit(1) + except Exception as e: + logger.error( + "Token refresher initialization failed unexpectedly", + error=str(e), + error_type=type(e).__name__, + ) + sys.exit(1) + + # Initialize reviewer token refresher (optional — for posting reviews with + # approve/request-changes using a separate GitHub App identity). + # Reviewer is optional so we don't retry or block startup. + try: + from token_refresher import initialize_reviewer_token_refresher + + reviewer_refresher = initialize_reviewer_token_refresher() + if reviewer_refresher: + logger.info("Reviewer token refresher initialized") + else: + logger.debug("Reviewer token refresher not configured (optional)") + except ImportError: + pass # Already logged above + except Exception as e: + logger.warning("Reviewer token refresher initialization failed", error=str(e)) + + # Validate user mode config if configured + github = _b().get_github_client() + is_valid, validation_msg = github.validate_user_mode_config() + if not is_valid: + logger.warning("User mode config validation failed", reason=validation_msg) + else: + logger.info("User mode config", status=validation_msg) + + # Load sessions BEFORE worktree cleanup so we know which containers are active. + # After a gateway restart, Docker CLI may not be available inside the container, + # so we derive the active container set from persisted sessions instead. + # + # Each session contributes its own ``container_id`` plus the per-agent + # and pipeline-level worktree anchor IDs ({pipeline_id}-{role} and + # {pipeline_id}). Without those derived anchors, cleanup would treat + # every live pipeline's per-agent worktree as orphaned because the + # on-disk dir name never matches the session container_id (#1874). + active_container_ids: set[str] = set() + try: + session_manager = _b().get_session_manager() + pruned = session_manager.prune_expired_sessions() + if pruned > 0: + logger.info(f"Startup session cleanup pruned {pruned} expired session(s)") + # Extract active container IDs from surviving sessions, plus the + # per-agent/pipeline worktree anchors the orchestrator assigns. + sessions = session_manager.list_sessions() + active_container_ids |= _b()._container_ids_from_sessions(sessions) + if active_container_ids: + logger.info( + "Active containers from sessions", + count=len(active_container_ids), + ) + except Exception as e: + logger.warning("Startup session cleanup failed", error=str(e)) + + # Also check Docker directly as safety net — sessions may be + # pruned but containers still running. + try: + docker_containers = _b().get_active_docker_containers() + active_container_ids |= docker_containers + except Exception as e: + logger.warning("Could not query Docker containers", error=str(e)) + + # Clean up orphaned worktrees in a background thread so it doesn't block + # the Waitress thread pool at startup. Worktree cleanup involves synchronous + # git operations that can hold threads for seconds each, and with many + # orphaned sessions this was exhausting the thread pool before the gateway + # could serve any requests. See: https://github.com/jwbron/egg/issues/1400 + def _background_worktree_cleanup() -> None: + try: + # Container liveness alone cannot distinguish a crashed leftover + # from a pipeline parked at a HITL gate (no containers, no + # sessions — that's its normal state). Ask the orchestrator which + # pipelines are live before sweeping; on a redeploy it may still + # be booting, so poll up to the configured deadline. If it never + # answers, startup_cleanup skips the sweep (fail-safe) — see + # #3070, where a blind sweep with active_containers=0 deleted a + # parked pipeline's worktree, contract, and branches. + active_pipeline_ids = _b().wait_for_active_pipeline_ids() + orphans_removed = _b().startup_cleanup( + active_containers=active_container_ids, + session_manager=_b().get_session_manager(), + active_pipeline_ids=active_pipeline_ids, + ) + if orphans_removed > 0: + logger.info(f"Startup cleanup removed {orphans_removed} orphaned worktree(s)") + except Exception as e: + logger.warning("Startup worktree cleanup failed", error=str(e)) + + cleanup_thread = threading.Thread( + target=_background_worktree_cleanup, + name="startup-worktree-cleanup", + daemon=True, + ) + cleanup_thread.start() + + # Start background session pruner so stale entries don't accumulate across + # restarts. Without this, sessions for dead containers survive until their + # 24h TTL lapses and are reloaded on every gateway restart (#1884). + try: + prune_interval = max(1, int(os.environ.get("EGG_SESSION_CLEANUP_INTERVAL_MINUTES", "15"))) + idle_timeout = max(5, int(os.environ.get("EGG_SESSION_IDLE_TIMEOUT_MINUTES", "60"))) + _b().get_session_manager().start_background_pruner( + interval_minutes=prune_interval, + idle_timeout_minutes=idle_timeout, + ) + except Exception as e: + logger.warning("Failed to start session background pruner", error=str(e)) + + # Ensure launcher secret is configured - fail startup if not + try: + _b().get_launcher_secret() + except _b().LauncherSecretNotConfiguredError as e: + logger.error("Startup failed: launcher secret not configured", error=str(e)) + sys.exit(1) + + # Under k8s the compose-era default hostname "egg-orchestrator" does + # not resolve, so falling back to it produces cryptic "Orchestrator + # unreachable" errors on the agent side mid-pipeline. Fail startup + # instead so the misconfiguration is visible at deploy time (#1803). + if os.environ.get("KUBERNETES_SERVICE_HOST") and not os.environ.get("EGG_ORCHESTRATOR_URL"): + logger.error( + "Startup failed: EGG_ORCHESTRATOR_URL must be set when running in Kubernetes. " + "Set it on the gateway Deployment, e.g. " + "http://orchestrator.egg-system.svc.cluster.local:9849" + ) + sys.exit(1) + + # Register SIGHUP handler for config reload. + # Usage: docker kill -s HUP egg-gateway + def _handle_sighup(signum: int, frame: Any) -> None: + _b()._reload_all_config() + + signal.signal(signal.SIGHUP, _handle_sighup) + + # Register SIGTERM handler for graceful shutdown. + # When Docker sends SIGTERM, delay for 5s to let in-flight session cleanup + # requests complete before exiting. This prevents the race condition where + # the gateway becomes unreachable before the launcher's cleanup hook runs. + # NOTE: This is a delay, not a true drain — waitress continues accepting new + # requests during the sleep. If a new long-running request starts during this + # window, it will be killed when sys.exit(0) fires. For our use case (Docker + # stop), this is acceptable since the only in-flight requests at shutdown are + # short-lived session cleanup calls. + def _handle_shutdown(signum: int, frame: Any) -> None: + logger.info("Received SIGTERM, delaying 5s for in-flight requests before shutdown...") + time.sleep(5) + sys.exit(0) + + signal.signal(signal.SIGTERM, _handle_shutdown) + + logger.info( + "Starting Gateway Sidecar", + host=args.host, + port=args.port, + debug=args.debug, + threads=args.threads, + health_port=args.health_port, + ) + logger.info("Session authentication required for all container operations") + + # Optional tracemalloc sampler (opt-in via GATEWAY_MEM_TRACE=1). Emits + # periodic RSS + top-allocation-site log records to stdout so the trail + # survives pod OOM via `kubectl logs --previous`. See #1885. + try: + from ..mem_trace import start_if_enabled as _mem_trace_start + except ImportError: + from mem_trace import ( # type: ignore[no-redef, import-untyped] + start_if_enabled as _mem_trace_start, + ) + _mem_trace_start() + + # Start dedicated health check server on a separate port so Docker/orchestrator + # health checks are never blocked by long-running git operations on the main + # Waitress thread pool. See: https://github.com/jwbron/egg/issues/1400 + health_thread = threading.Thread( + target=_run_health_server, + args=(args.host, args.health_port), + name="health-check-server", + daemon=True, + ) + health_thread.start() + logger.info("Dedicated health check server started", port=args.health_port) + + # Run with production server in production, debug server in debug mode + if args.debug: + _b().app.run(host=args.host, port=args.port, debug=True) + else: + # Use waitress for production with configurable thread pool. + # Increased from 8 (previous default) to 32 to handle concurrent load + # from multiple SDLC pipelines. See: https://github.com/jwbron/egg/issues/1400 + _b().serve(_b().app, host=args.host, port=args.port, threads=args.threads) diff --git a/gateway/gateway/_sessions.py b/gateway/gateway/_sessions.py new file mode 100644 index 0000000000..ff65a72a3b --- /dev/null +++ b/gateway/gateway/_sessions.py @@ -0,0 +1,943 @@ +"""Gateway sessions cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import re +import subprocess +from typing import Any + +from flask import Response, jsonify, request + +try: + from ..git_client import ( + git_cmd, + ) + from ..phase_filter import ( + PipelinePhase, + ) + from ..rate_limiter import ( + check_heartbeat_rate_limit, + record_failed_lookup, + ) + from ..repo_parser import ( + parse_owner_repo, + ) + from ..worktree_manager import ( + WorktreeManager, + validate_branch_ref, + ) +except ImportError: # flat/container import mode + from git_client import ( # type: ignore[no-redef, import-untyped] + git_cmd, + ) + from phase_filter import ( # type: ignore[no-redef, import-untyped] + PipelinePhase, + ) + from rate_limiter import ( # type: ignore[no-redef, import-untyped] + check_heartbeat_rate_limit, + record_failed_lookup, + ) + from repo_parser import ( # type: ignore[no-redef, import-untyped] + parse_owner_repo, + ) + from worktree_manager import ( # type: ignore[no-redef, import-untyped] + WorktreeManager, + validate_branch_ref, + ) + +from ._helpers import make_error, make_success + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + + +def _branch_exists_on_remote(manager: WorktreeManager, repo_name: str, branch: str) -> bool: + """Check if a branch exists on the remote (origin) for a repository. + + Args: + manager: WorktreeManager instance (provides repos_base path) + repo_name: Name of the repository + branch: Branch name without origin/ prefix (e.g., "egg/issue-42/work") + + Returns: + True if origin/{branch} exists, False otherwise. + """ + main_repo = manager.repos_base / repo_name + if not main_repo.exists(): + return False + # Uses local tracking refs (origin/*) rather than querying the remote. + # This is reliable here because the gateway handles push/fetch operations + # which keep tracking refs up to date. If stale refs ever become an + # issue, switch to `git ls-remote --exit-code origin {branch}`. + result = subprocess.run( + git_cmd("rev-parse", "--verify", f"origin/{branch}"), + cwd=main_repo, + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + + +def session_create() -> tuple[Response, int] | Response: + """ + Create a session with atomic visibility query, filtering, worktree creation. + + This is the primary endpoint for session registration. It performs: + 1. Query repository visibility for all requested repos + 2. Filter repos based on mode (private keeps private/internal, public keeps public) + 3. Create worktrees for filtered repos + 4. Register session with the filtered repo list + + This atomic operation prevents TOCTOU race conditions between visibility + check and session registration. + + Request body: + { + "container_id": "egg-xxx", + "container_ip": "172.18.0.3", + "mode": "private"|"public", + "repos": ["owner/repo1", "owner/repo2"], + "local_only_repos": ["repo-name"], // optional; no GitHub remote + "uid": 1000, + "gid": 1000, + // Optional: when the caller already created per-agent worktrees + // via /api/v1/worktrees/create, pass that container_id here so + // the session reuses the existing worktrees instead of racing + // to re-create them on the same bare repo (#1857). + "worktree_container_id": "pipeline-role" + } + + Response: + { + "success": true, + "session_token": "tok_...", + "filtered_repos": ["owner/repo1"], + "worktrees": { + "repo1": "/path/to/worktree" + } + } + + Auth: Bearer {launcher_secret} + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + container_id = data.get("container_id") + container_ip = data.get("container_ip") + mode = data.get("mode") + repos = data.get("repos", []) + local_only_repos = data.get("local_only_repos", []) + uid = data.get("uid") + gid = data.get("gid") + # Optional worktree container_id — when provided, look up existing + # worktrees created by a prior /api/v1/worktrees/create call instead of + # re-creating them here. Prevents the double create that races on + # .git/config.lock for concurrent per-agent spawns (#1857). + worktree_container_id = data.get("worktree_container_id") + phase = data.get("phase") # Optional SDLC pipeline phase + pipeline_id = data.get("pipeline_id") # Optional pipeline run ID + issue_number = data.get("issue_number") # Optional GitHub issue number + pr_number = data.get("pr_number") # Optional GitHub PR number + agent_role = data.get("agent_role") # Optional agent role + agent_anchor_id = data.get("agent_anchor_id") # Optional agent anchor ID + claude_code_version = data.get("claude_code_version") # Optional Claude Code version + branch = data.get("branch") # Optional git branch for non-pushing sessions + # Optional pipeline base branch (PR base). Stored on the session and used + # as the preferred diff base for the new-branch restricted-path push check + # (#3024). Distinct from the locally-derived ``worktree_base_branch`` below, + # which selects the ref a fresh worktree forks from. + session_base_branch = data.get("base_branch") + jira_ticket = data.get("jira_ticket") # Optional Atlassian ticket key — advisory only + synthetic = data.get("synthetic", False) # Orchestrator-internal temp session + # Per-session upstream routing (issue #2769). Default to "anthropic" so + # pre-#2769 callers keep byte-identical session shape. + upstream = data.get("upstream", "anthropic") + upstream_model = data.get("upstream_model") + + # Validate required fields + if not container_id: + return make_error("Missing container_id") + # container_ip is optional — k8s pod IPs are ephemeral and may not be + # known at session creation time. When omitted, token-only auth is used. + # Kept for backward compatibility with Docker-based deployments. + if mode not in ("private", "public"): + return make_error("Invalid mode: must be 'private' or 'public'") + # repos can be omitted for orchestrator-internal sessions that have a pipeline_id + if not repos and not local_only_repos and not pipeline_id: + return make_error("Missing repos list") + + # Validate uid/gid if provided + if uid is not None and (not isinstance(uid, int) or uid < 0): + return make_error("Invalid uid: must be a non-negative integer") + if gid is not None and (not isinstance(gid, int) or gid < 0): + return make_error("Invalid gid: must be a non-negative integer") + + # Validate phase if provided + if phase is not None and phase not in VALID_PIPELINE_PHASES: + return make_error( + f"Invalid phase: {phase}. Must be one of: {', '.join(sorted(VALID_PIPELINE_PHASES))}" + ) + + # Validate pipeline_id if provided + if pipeline_id is not None: + if not isinstance(pipeline_id, str): + return make_error("Invalid pipeline_id: must be a string") + if not pipeline_id: + return make_error("Invalid pipeline_id: must be a non-empty string") + if len(pipeline_id) > 256: + return make_error("Invalid pipeline_id: must be 256 characters or fewer") + + # Validate base_branch if provided (#3024) + # + # Defense-in-depth: ``base_branch`` flows into ``git fetch`` and + # ``git merge-base origin/ HEAD`` as positional argv (see + # ``get_changed_files_in_push`` / ``_enumerate_push_commits``). A value + # starting with ``-`` would otherwise be interpreted as a git flag (the + # historical ``--upload-pack=...`` shape that has produced git RCEs). + # The orchestrator already validates ``base_branch`` at pipeline submission + # and the launcher secret gates this endpoint, but the gateway must not + # rely on caller hygiene here — ``validate_branch_ref`` rejects leading + # dashes, ``..``, null bytes, ``//``, and other unsafe ref shapes. + if session_base_branch is not None: + if not isinstance(session_base_branch, str): + return make_error("Invalid base_branch: must be a string") + if not session_base_branch: + return make_error("Invalid base_branch: must be a non-empty string") + if len(session_base_branch) > 256: + return make_error("Invalid base_branch: must be 256 characters or fewer") + try: + validate_branch_ref(session_base_branch, "base_branch") + except ValueError as exc: + return make_error(str(exc)) + + # Validate issue_number if provided + if issue_number is not None and (not isinstance(issue_number, int) or issue_number < 1): + return make_error("Invalid issue_number: must be a positive integer") + + # Validate pr_number if provided + if pr_number is not None and (not isinstance(pr_number, int) or pr_number < 1): + return make_error("Invalid pr_number: must be a positive integer") + + # Validate agent_role if provided + if agent_role is not None: + if not isinstance(agent_role, str): + return make_error("Invalid agent_role: must be a string") + if not agent_role: + return make_error("Invalid agent_role: must be non-empty if provided") + if len(agent_role) > 64: + return make_error("Invalid agent_role: must be 64 characters or fewer") + + # Validate agent_anchor_id if provided + if agent_anchor_id is not None: + if not isinstance(agent_anchor_id, str): + return make_error("Invalid agent_anchor_id: must be a string") + if len(agent_anchor_id) > 128: + return make_error("Invalid agent_anchor_id: must be 128 characters or fewer") + if not re.match(r"^[a-zA-Z0-9_-]+$", agent_anchor_id): + return make_error( + "Invalid agent_anchor_id: must contain only alphanumeric characters, hyphens, and underscores" + ) + + # Validate claude_code_version if provided + if claude_code_version is not None: + if not isinstance(claude_code_version, str): + return make_error("Invalid claude_code_version: must be a string") + if len(claude_code_version) > 64: + return make_error("Invalid claude_code_version: must be 64 characters or fewer") + + # Validate branch if provided + # + # Same argv-injection concern as ``base_branch`` above — ``branch`` flows + # into ``git fetch origin `` and ``git rev-list origin/..HEAD`` + # in the push handler, so refuse leading dashes and other unsafe ref shapes + # via ``validate_branch_ref`` regardless of upstream callers' own checks. + if branch is not None: + if not isinstance(branch, str): + return make_error("Invalid branch: must be a string") + if len(branch) > 256: + return make_error("Invalid branch: must be 256 characters or fewer") + try: + validate_branch_ref(branch, "branch") + except ValueError as exc: + return make_error(str(exc)) + + # Validate synthetic if provided + if not isinstance(synthetic, bool): + return make_error("Invalid synthetic: must be a boolean") + + # Validate upstream / upstream_model (issue #2769). + # ``upstream`` must be a name the UpstreamRegistry will serve — refuse + # silently routing unknown upstreams to Anthropic. + if not isinstance(upstream, str): + return make_error("Invalid upstream: must be a string") + if not _b().get_upstream_registry().is_known(upstream): + known = ", ".join(sorted(_b().get_upstream_registry().known_upstreams())) + return make_error(f"Invalid upstream: '{upstream}'. Must be one of: {known}") + if upstream_model is not None: + if not isinstance(upstream_model, str): + return make_error("Invalid upstream_model: must be a string") + if not upstream_model: + return make_error("Invalid upstream_model: must be non-empty if provided") + if len(upstream_model) > 256: + return make_error("Invalid upstream_model: must be 256 characters or fewer") + + # Validate worktree_container_id if provided + if worktree_container_id is not None: + if not isinstance(worktree_container_id, str): + return make_error("Invalid worktree_container_id: must be a string") + if not worktree_container_id: + return make_error("Invalid worktree_container_id: must be non-empty if provided") + if len(worktree_container_id) > 256: + return make_error("Invalid worktree_container_id: must be 256 characters or fewer") + if ".." in worktree_container_id or not re.match( + r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$", worktree_container_id + ): + return make_error("Invalid worktree_container_id: contains unsafe characters") + + # Validate local_only_repos if provided + if local_only_repos: + if not isinstance(local_only_repos, list): + return make_error("Invalid local_only_repos: must be a list") + if len(local_only_repos) > 50: + return make_error("Invalid local_only_repos: too many entries") + for repo_name in local_only_repos: + if not isinstance(repo_name, str): + return make_error("Invalid local_only_repos: all items must be strings") + if not repo_name or len(repo_name) > 256: + return make_error("Invalid local_only_repos: repo name must be 1-256 chars") + if ".." in repo_name or "/" in repo_name: + return make_error(f"Invalid local_only_repos: unsafe repo name '{repo_name}'") + + # Step 1: Query visibility for all repos + repo_visibilities = {} + for repo in repos: + repo_info = parse_owner_repo(repo) + if repo_info: + visibility = _b().get_repo_visibility(repo_info.owner, repo_info.repo) + repo_visibilities[repo] = visibility + else: + # Can't parse repo - skip it + logger.warning( + "Could not parse repository for visibility check", + repo=repo, + container_id=container_id, + ) + + # Step 2: Filter repos based on mode + # private mode: include repos with known visibility (private, internal, public). + # Write access is controlled separately by push policy (only private/internal + # repos are writable). The network is locked down — mounting a public repo + # in private mode doesn't grant broader internet access. + # Repos with unknown visibility (None) are excluded (fail-closed). + # public mode: keep only public repos (don't mount private repos on open network) + filtered_repos = [] + for repo, visibility in repo_visibilities.items(): + if mode == "private": + # Private mode: include repos with known visibility — network is + # locked down anyway so mounting a public repo is safe. + # Push policy enforces write restrictions to private/internal repos. + if visibility is None: + # Unknown visibility — repo may not exist or API unreachable. + # Fail closed: don't attempt to mount a repo we can't verify. + logger.warning( + "Unknown visibility for repo, excluding in private mode", + repo=repo, + container_id=container_id, + ) + continue + elif visibility not in ("private", "internal"): + logger.info( + "Including public repo in private mode (network locked down)", + repo=repo, + visibility=visibility, + container_id=container_id, + ) + filtered_repos.append(repo) + else: + # Public mode: only mount public repos + if visibility is None: + # Unknown visibility — can't confirm public, exclude + logger.warning( + "Unknown visibility for repo, excluding in public mode", + repo=repo, + container_id=container_id, + ) + elif visibility == "public": + filtered_repos.append(repo) + else: + logger.debug( + "Excluding non-public repo in public mode", + repo=repo, + visibility=visibility, + container_id=container_id, + ) + + # Step 2b: Include local-only repos in private mode. + # These repos have no GitHub remote so GitHub visibility cannot be checked. + # They are always treated as private: included in private mode, excluded in public mode. + if local_only_repos and mode == "private": + for repo_name in local_only_repos: + filtered_repos.append(repo_name) + logger.info( + "Including local-only repo in private mode", + repo=repo_name, + container_id=container_id, + ) + elif local_only_repos and mode != "private": + logger.debug( + "Excluding local-only repos in public/local mode", + repos=local_only_repos, + mode=mode, + container_id=container_id, + ) + + # Step 3: Create worktrees for filtered repos + worktrees = {} + worktree_errors = [] + first_worktree_path: str | None = None # Gateway-side path for the session's repo context + + # Only initialise the worktree manager when there are repos to process. + # Local-mode sessions (no repos) skip worktree creation entirely, so + # avoid hitting the filesystem for the worktree base directory. + if filtered_repos: + manager = _b().get_worktree_manager() + + for repo in filtered_repos: + # Extract repo name from owner/repo format + if "/" in repo: + repo_name = repo.split("/")[-1] + else: + repo_name = repo + + try: + if worktree_container_id: + # Reuse worktrees created by a prior /api/v1/worktrees/create + # call. Avoids a second concurrent ``git worktree add`` on the + # same bare repo, which races on ``.git/config.lock`` (#1857). + info = manager.lookup_worktree( + repo_name=repo_name, + container_id=worktree_container_id, + ) + else: + # For pipeline sessions, prefer the pipeline's existing worktree + # branch (which contains artifacts from prior agents) over a fresh + # branch from origin/main. This ensures HITL exec sessions can + # see drafts, contracts, and reviews committed by pipeline agents. + # See #1016. + # + # For fresh pipelines (no prior worktree branch), fall back to the + # remote default branch (e.g., origin/main) instead of HEAD. HEAD + # may point to a feature branch in the main repo, which would + # pollute the worktree with commits outside the current phase's + # allowed scope and cause push rejections. See #860. + if pipeline_id: + pipeline_work_branch = f"egg/{pipeline_id}/work" + if _b()._branch_exists_on_remote(manager, repo_name, pipeline_work_branch): + worktree_base_branch = f"origin/{pipeline_work_branch}" + else: + worktree_base_branch = manager.resolve_default_branch(repo_name) + else: + worktree_base_branch = "HEAD" + + info = manager.create_worktree( + repo_name=repo_name, + container_id=container_id, + base_branch=worktree_base_branch, + uid=uid, + gid=gid, + assigned_branch=branch, + repo_slug=repo, + ) + # Capture the first worktree's gateway-side path for the session's repo context + if first_worktree_path is None: + first_worktree_path = str(info.worktree_path) + # Translate container path to host path for egg launcher mount sources + worktrees[repo_name] = _b().translate_to_host_path(str(info.worktree_path)) + except ValueError as e: + worktree_errors.append(f"{repo_name}: {e}") + except RuntimeError as e: + worktree_errors.append(f"{repo_name}: {e}") + except Exception as e: + worktree_errors.append(f"{repo_name}: unexpected error - {e}") + + # If no worktrees could be created, fail + if not worktrees and filtered_repos: + return make_error( + "Failed to create any worktrees", + status_code=500, + details={"errors": worktree_errors}, + ) + + # Step 4: Register session + session_manager = _b().get_session_manager() + token, _session = session_manager.register_session( + container_id=container_id, + container_ip=container_ip, + mode=mode, + phase=phase, + pipeline_id=pipeline_id, + issue_number=issue_number, + pr_number=pr_number, + agent_role=agent_role, + agent_anchor_id=agent_anchor_id, + claude_code_version=claude_code_version, + branch=branch, + base_branch=session_base_branch, + jira_ticket=jira_ticket if isinstance(jira_ticket, str) and jira_ticket else None, + synthetic=synthetic, + upstream=upstream, + upstream_model=upstream_model, + ) + + # Pre-populate the session's repo path so non-pushing sessions (reviewers, + # architects, etc.) can resolve their worktree before any git push. This is + # also set on git push, but pipeline agents that never push would otherwise + # have a None value. + if first_worktree_path is not None: + _session.last_repo_path = first_worktree_path + + # Use the shared pipeline branch for push enforcement. + # session_manager already sets assigned_branch from the `branch` + # request parameter (session_manager.py:560-564). If that wasn't + # provided, fall back to the canonical pipeline branch name. + if pipeline_id and not _session.assigned_branch: + _session.assigned_branch = f"egg/{pipeline_id}/work" + + _b().audit_log( + "session_created", + "session_create", + success=True, + details={ + "container_id": container_id, + "container_ip": container_ip, + "mode": mode, + "phase": phase, + "pipeline_id": pipeline_id, + "issue_number": issue_number, + "pr_number": pr_number, + "agent_role": agent_role, + "filtered_repos": filtered_repos, + "worktree_count": len(worktrees), + "worktree_errors": worktree_errors if worktree_errors else None, + "upstream": upstream, + "upstream_model": upstream_model, + }, + ) + + return make_success( + "Session created", + { + "session_token": token, + "filtered_repos": filtered_repos, + "worktrees": worktrees, + "errors": worktree_errors if worktree_errors else None, + }, + ) + + +def _cleanup_container_worktrees( + container_id: str, +) -> tuple[list[str], list[str]]: + """Clean up all worktrees for a container. + + Returns: + Tuple of (deleted_repo_names, errors). + """ + manager = _b().get_worktree_manager() + worktree_dir = manager.worktree_base / container_id + deleted_worktrees: list[str] = [] + errors: list[str] = [] + if worktree_dir.exists(): + for repo_dir in list(worktree_dir.iterdir()): + if not repo_dir.is_dir(): + continue + repo_name = repo_dir.name + try: + result = manager.remove_worktree( + container_id=container_id, + repo_name=repo_name, + force=True, + ) + if result.success: + deleted_worktrees.append(repo_name) + elif result.error: + errors.append(f"{repo_name}: {result.error}") + else: + errors.append(f"{repo_name}: removal failed") + except Exception as e: + errors.append(f"{repo_name}: unexpected error - {e}") + return deleted_worktrees, errors + + +def session_delete(session_token: str) -> tuple[Response, int] | Response: + """ + Delete a session. + + Only the launcher (with launcher_secret) can delete sessions. + Containers CANNOT delete sessions. + + Also cleans up associated worktrees. + + Args: + session_token: The session token to delete + + Auth: Bearer {launcher_secret} + """ + session_manager = _b().get_session_manager() + + # Get session info for worktree cleanup + session = session_manager.get_session(session_token) + container_id = session.container_id if session else None + + # Delete the session + deleted = session_manager.delete_session(session_token) + + if not deleted: + return make_error("Session not found", status_code=404) + + # _capture_and_cleanup_session (called inside delete_session) auto-commits + # the agent's WIP synchronously before returning, so the worktree is safe + # to remove at this point. + + # Clean up worktrees for this container + deleted_worktrees, worktree_errors = ( + _cleanup_container_worktrees(container_id) if container_id else ([], []) + ) + + _b().audit_log( + "session_deleted", + "session_delete", + success=True, + details={ + "container_id": container_id, + "worktrees_deleted": deleted_worktrees, + "errors": worktree_errors if worktree_errors else None, + }, + ) + + return make_success("Session deleted") + + +def session_delete_by_container(container_id: str) -> tuple[Response, int] | Response: + """ + Delete a session by container ID. + + Used by the orchestrator for cleanup when the session token is not available. + + Args: + container_id: The container ID whose session to delete + + Auth: Bearer {launcher_secret} + """ + session_manager = _b().get_session_manager() + deleted = session_manager.delete_session_by_container(container_id) + + if not deleted: + return make_error("Session not found for container", status_code=404) + + # _capture_and_cleanup_session (called inside delete_session_by_container) + # auto-commits the agent's WIP synchronously before returning, so the + # worktree is safe to remove at this point. + + # Clean up worktrees for this container + deleted_worktrees, worktree_errors = _cleanup_container_worktrees(container_id) + + _b().audit_log( + "session_deleted", + "session_delete_by_container", + success=True, + details={ + "container_id": container_id, + "worktrees_deleted": deleted_worktrees, + "errors": worktree_errors if worktree_errors else None, + }, + ) + + return make_success("Session deleted") + + +def session_heartbeat_by_container(container_id: str) -> tuple[Response, int] | Response: + """ + Refresh a session's idle timer by container ID (orchestrator-only path). + + Used by the orchestrator to keep agent sessions alive while their + container is heartbeating on the BRC bus but not making gateway + requests — without this, the idle pruner evicts the session after + EGG_SESSION_IDLE_TIMEOUT_MINUTES even though the agent is still + working (see #2068). + + Auth: Bearer {launcher_secret} + + Returns 404 if no session exists for the container. No per-session + rate limit because the launcher secret already gates access — only + the orchestrator can call this. + """ + session_manager = _b().get_session_manager() + refreshed = session_manager.heartbeat_session_by_container(container_id) + + if not refreshed: + return make_error("Session not found for container", status_code=404) + + return make_success("Heartbeat recorded") + + +def session_get(session_token: str) -> tuple[Response, int] | Response: + """ + Get session information and validate if it exists. + + Args: + session_token: The session token + + Auth: Bearer {launcher_secret} + + Response: + { + "valid": true, + "mode": "private"|"public", + "container_id": "...", + "expires_at": "...", + } + """ + session_manager = _b().get_session_manager() + result = session_manager.validate_session(session_token) + + if not result.valid: + return jsonify({"valid": False, "error": result.error or "Session not found"}), 404 + + return jsonify( + { + "valid": True, + "mode": result.session.mode if result.session else None, + "container_id": result.session.container_id if result.session else None, + "expires_at": result.session.expires_at.isoformat() if result.session else None, + } + ) + + +def session_heartbeat(session_token: str) -> tuple[Response, int] | Response: + """ + Explicit session heartbeat to extend TTL. + + Note: Heartbeats are also triggered implicitly on any successful + session-authenticated request. This endpoint exists for edge cases + where long-running operations need TTL extension without git/gh activity. + + Args: + session_token: The session token + + Auth: Bearer {session_token} + + Rate limit: 100 per hour per session + """ + # Validate the session + result = _b().validate_session_for_request(session_token, request.remote_addr) + if not result.valid: + # Record failed lookup for rate limiting + record_failed_lookup(request.remote_addr or "") + return make_error(result.error or "Invalid session", status_code=401) + + # Check heartbeat rate limit (100 per hour per session) + if result.session: + rate_limit = check_heartbeat_rate_limit(result.session.session_token_hash) + if not rate_limit.allowed: + return make_error( + f"Heartbeat rate limit exceeded. Retry after {rate_limit.retry_after_seconds}s", + status_code=429, + ) + + # Session validation already extends TTL, just return success + return make_success( + "Heartbeat recorded", + { + "expires_at": result.session.expires_at.isoformat() if result.session else None, + }, + ) + + +def session_update(session_token: str) -> tuple[Response, int] | Response: + """ + Update session container binding (container_id and/or container_ip). + + Used by the orchestrator to bind a session to the real container + after pre-registering with a placeholder ID before container creation. + + Request body: + { + "container_id": "abc123...", # Optional + "container_ip": "172.32.0.10" # Optional + } + + At least one of container_id or container_ip must be provided. + + Args: + session_token: The session token to update + + Auth: Bearer {launcher_secret} + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + container_id = data.get("container_id") + container_ip = data.get("container_ip") + + if not container_id and not container_ip: + return make_error("Must provide container_id and/or container_ip") + + session_manager = _b().get_session_manager() + success = session_manager.update_session( + session_token, + container_id=container_id, + container_ip=container_ip, + ) + + if not success: + return make_error("Session not found or expired", status_code=404) + + _b().audit_log( + "session_container_updated", + "session_update", + success=True, + details={ + "container_id": container_id, + "container_ip": container_ip, + }, + ) + + return make_success( + "Session updated", + { + "container_id": container_id, + "container_ip": container_ip, + }, + ) + + +VALID_PIPELINE_PHASES = frozenset(p.value for p in PipelinePhase) + + +def session_update_phase(session_token: str) -> tuple[Response, int] | Response: + """ + Update the SDLC pipeline phase for a session. + + This endpoint allows the launcher/workflow to update the phase as + the pipeline progresses. Phase restrictions are enforced by the + gateway for operations like PR creation. + + Request body: + { + "phase": "refine"|"plan"|"implement"|"pr" + } + + Args: + session_token: The session token to update + + Auth: Bearer {launcher_secret} + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + phase = data.get("phase") + if not phase: + return make_error("Missing phase") + + if phase not in VALID_PIPELINE_PHASES: + return make_error( + f"Invalid phase: {phase}. Must be one of: {', '.join(sorted(VALID_PIPELINE_PHASES))}" + ) + + session_manager = _b().get_session_manager() + success = session_manager.update_phase(session_token, phase) + + if not success: + return make_error("Session not found or expired", status_code=404) + + _b().audit_log( + "session_phase_updated", + "session_update_phase", + success=True, + details={"phase": phase}, + ) + + return make_success("Phase updated", {"phase": phase}) + + +def repos_visibility() -> tuple[Response, int] | Response: + """ + Query visibility for multiple repositories. + + Used by launcher for informational queries. For atomic session+worktree + creation, use POST /api/v1/sessions/create instead. + + Query params: + repos: Comma-separated list of owner/repo strings + + Response: + { + "visibilities": { + "owner/repo1": "public", + "owner/repo2": "private", + "owner/repo3": "internal" + } + } + + Auth: Bearer {launcher_secret} + """ + repos_param = request.args.get("repos", "") + if not repos_param: + return make_error("Missing repos query parameter") + + repos = [r.strip() for r in repos_param.split(",") if r.strip()] + if not repos: + return make_error("No valid repos provided") + + visibilities = {} + for repo in repos: + repo_info = parse_owner_repo(repo) + if repo_info: + visibility = _b().get_repo_visibility(repo_info.owner, repo_info.repo) + visibilities[repo] = visibility + else: + visibilities[repo] = None + + return make_success("Visibility queried", {"visibilities": visibilities}) + + +def sessions_list() -> tuple[Response, int] | Response: + """ + List all active sessions. + + Auth: Bearer {launcher_secret} + """ + session_manager = _b().get_session_manager() + sessions = session_manager.list_sessions() + return make_success("Sessions listed", {"sessions": sessions}) diff --git a/gateway/gateway/_worktree.py b/gateway/gateway/_worktree.py new file mode 100644 index 0000000000..7a6acf73a2 --- /dev/null +++ b/gateway/gateway/_worktree.py @@ -0,0 +1,741 @@ +"""Gateway worktree cluster (#3312 slice-3 extraction from gateway.py). + +Pure refactor: handler/helper bodies are AST-identical to the pre-split +gateway.py. Route @app.route decorators stay on thin wrappers in the barrel +(gateway/gateway/__init__.py); this module holds their implementations, and +the barrel re-exports every symbol here so gateway.gateway. resolves. +""" + +from __future__ import annotations + +import re +import threading +from pathlib import Path +from typing import Any + +from flask import Response, request + +try: + from ..worktree_manager import ( + REPOS_BASE_DIR, + validate_identifier, + ) +except ImportError: # flat/container import mode + from worktree_manager import ( # type: ignore[no-redef, import-untyped] + REPOS_BASE_DIR, + validate_identifier, + ) + +from ._helpers import make_error, make_success + + +def _b() -> Any: + """Return the gateway barrel for call-time lookup of patched symbols. + + Seam getters/validators and gateway-local helpers are patched by tests at + ``gateway.gateway.``; resolving them on the barrel at call time keeps + those patches effective after the split. + """ + import sys + + return sys.modules.get("gateway.gateway") or sys.modules["gateway"] + + +class _BarrelLogger: + """Proxy to the barrel ``logger`` so tests patching ``gateway.logger`` + observe log calls emitted from this submodule.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_b().logger, name) + + +logger: Any = _BarrelLogger() + + +def map_container_path_to_worktree( + repo_path: str, container_id: str | None, operation: str = "git" +) -> str | None: + """ + Map a container's repo path to the corresponding worktree path. + + Container sends paths like /home/egg/repos/{repo} or subdirectories like + /home/egg/repos/{repo}/src/foo, but the gateway needs to run git in the + worktree at /home/egg/.egg-worktrees/{container_id}/{repo}[/subdir]. + + Args: + repo_path: The path sent by the container (e.g., /home/egg/repos/myrepo/src) + container_id: The container's unique identifier + operation: Name of the operation for logging purposes + + Returns: + The worktree path if mapping succeeds, the original repo_path if no + container_id was provided (interactive session), or None if a + container_id was provided but the worktree could not be found. + """ + if not container_id: + return repo_path + + # Extract repo name and any subdirectory from paths like: + # /home/egg/repos/myrepo -> repo_name=myrepo, subdir="" + # /home/egg/repos/myrepo/src/foo -> repo_name=myrepo, subdir="src/foo" + repos_prefix = "/home/egg/repos/" + if not repo_path.startswith(repos_prefix): + return repo_path + + # Get the path relative to /home/egg/repos/ + relative_path = repo_path[len(repos_prefix) :].rstrip("/") + if not relative_path: + # Path is exactly /home/egg/repos/ - not a repo + return repo_path + + # Split into repo name and subdirectory + parts = relative_path.split("/", 1) + repo_name = parts[0] + subdir = parts[1] if len(parts) > 1 else "" + + if not repo_name: + return repo_path + + manager = _b().get_worktree_manager() + try: + worktree_path, _main_repo = manager.get_worktree_paths(container_id, repo_name) + if worktree_path.exists(): + # Append subdirectory if present + final_path = worktree_path / subdir if subdir else worktree_path + logger.debug( + f"Mapped container path to worktree for {operation}", + container_path=repo_path, + worktree_path=str(final_path), + container_id=container_id, + ) + return str(final_path) + else: + logger.warning( + f"Worktree path does not exist for {operation} — " + f"container_id may not match any created worktree", + container_path=repo_path, + expected_worktree=str(worktree_path), + container_id=container_id, + ) + return None + except ValueError as e: + logger.warning( + f"Failed to map container path to worktree for {operation}", + error=str(e), + container_id=container_id, + repo_name=repo_name, + ) + return None + + +def _cleanup_stale_pack_files(exec_path: str) -> None: + """Best-effort opportunistic cleanup of previously-orphaned temporary pack files. + + Called after a git operation times out, but does NOT target the specific + operation's artifacts (those are too recent to match the age filter). + Instead, it scans for ``tmp_pack_*``/``tmp_obj_*``/``tmp_idx_*`` files + older than 5 minutes — orphans left by *earlier* interrupted operations. + The age filter avoids racing with concurrent fetch operations on the same + repository. + """ + try: + # Determine repo_name from exec_path. + # Worktree paths: /home/egg/.egg-worktrees/{container_id}/{repo_name}[/subdir] + # Main repo paths: /home/egg/repos/{repo_name}[/subdir] + repo_name = None + worktree_prefix = str(_b().WORKTREE_BASE_DIR) + "/" + repos_prefix = str(REPOS_BASE_DIR) + "/" + + if exec_path.startswith(worktree_prefix): + # e.g. /home/egg/.egg-worktrees/container-123/my-repo/src → my-repo + relative = exec_path[len(worktree_prefix) :] + parts = relative.split("/") + if len(parts) >= 2: + repo_name = parts[1] + elif exec_path.startswith(repos_prefix): + # e.g. /home/egg/repos/my-repo/src → my-repo + relative = exec_path[len(repos_prefix) :] + parts = relative.split("/") + if parts and parts[0]: + repo_name = parts[0] + + if not repo_name: + return + + manager = _b().get_worktree_manager() + manager.cleanup_orphaned_pack_files( + repo_name=repo_name, + max_age_seconds=300, + ) + except Exception as e: + logger.debug( + "Stale pack file cleanup failed (best-effort)", + exec_path=exec_path, + error=str(e), + ) + + +def _cleanup_empty_container_dir(container_id: str) -> None: + """Best-effort removal of an orphan container worktree directory. + + Called from the total-failure branch of worktree_create. Only acts + when the directory is actually empty — if any per-repo subdir is + present (partial failure with leftover state), we leave it for an + operator-driven prune so we don't accidentally drop in-progress + work. See #2186. + + Defense-in-depth: validate `container_id` and verify the resolved + path stays under `WORKTREE_BASE_DIR` before any filesystem mutation. + `worktree_create` already validates at the route boundary, but a + raw `..`-bearing identifier reaching `Path / container_id` would + otherwise let `rmdir(2)` follow the literal path and unlink an + empty directory adjacent to the base dir. + """ + try: + validate_identifier(container_id, "container_id") + except ValueError as e: + logger.warning( + "Skipping orphan container dir cleanup: invalid container_id", + container_id=container_id, + error=str(e), + ) + return + + target = _b().WORKTREE_BASE_DIR / container_id + try: + # Resolve and verify containment as a second line of defense + # against any future caller that bypasses validate_identifier. + base_resolved = _b().WORKTREE_BASE_DIR.resolve() + target_resolved = target.resolve(strict=False) + if target_resolved != base_resolved and base_resolved not in target_resolved.parents: + logger.warning( + "Skipping orphan container dir cleanup: outside base dir", + container_id=container_id, + resolved=str(target_resolved), + base=str(base_resolved), + ) + return + if not target.exists(): + return + if any(target.iterdir()): + logger.warning( + "Skipping orphan container dir cleanup: not empty", + container_id=container_id, + path=str(target), + ) + return + target.rmdir() + logger.info( + "Removed empty orphan container worktree dir", + container_id=container_id, + path=str(target), + ) + except OSError as e: + logger.warning( + "Failed to clean orphan container dir", + container_id=container_id, + path=str(target), + error=str(e), + ) + + +def worktree_create() -> tuple[Response, int] | Response: + """ + Create worktrees for a container. + + Called by the egg launcher before starting a container. Creates isolated + worktrees for each repository the container needs access to. + + Request body: + { + "container_id": "egg-xxx-yyy", + "repos": ["owner/repo1", "owner/repo2"], + "uid": 1000, // optional, defaults to 1000 (egg user) + "gid": 1000 // optional, defaults to 1000 (egg group) + } + + Returns: + { + "success": true, + "message": "Worktrees created", + "data": { + "worktrees": { + "repo1": "/home/user/.egg-worktrees/egg-xxx-yyy/repo1", + "repo2": "/home/user/.egg-worktrees/egg-xxx-yyy/repo2" + } + } + } + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + container_id = data.get("container_id") + repos = data.get("repos", []) + base_branch = data.get("base_branch") # None = resolve per-repo + assigned_branch = data.get("assigned_branch") # None = skip upstream config + # #3393 slice-7: when True, materialize each repo's pipeline work + # branch on its OWN remote right after the worktree exists (push the + # worktree HEAD to refs/heads/{assigned_branch or work-branch}). + # Multi-repo pipelines set this so secondary-repo context / slice PRs + # find a head branch; single-repo (N=1) callers leave it False, so + # the path stays byte-identical to pre-#3393. + push_branch = bool(data.get("push_branch", False)) + # UID/GID for worktree ownership (default: 1000 for egg user) + uid = data.get("uid") + gid = data.get("gid") + + if not container_id: + return make_error("Missing container_id") + if not repos: + return make_error("Missing repos list") + + # Validate container_id at the route boundary so every downstream + # filesystem touch (including the post-failure cleanup helper) is + # safe. Without this, a `..`-bearing container_id would reach + # `_cleanup_empty_container_dir` after `manager.create_worktree` + # raised ValueError into the per-repo `errors[]` list, letting + # `rmdir(2)` follow the literal path out of WORKTREE_BASE_DIR. + # See #2186 review feedback. + try: + validate_identifier(container_id, "container_id") + except ValueError as e: + return make_error(str(e)) + + # Validate uid/gid if provided + if uid is not None and (not isinstance(uid, int) or uid < 0): + return make_error("Invalid uid: must be a non-negative integer") + if gid is not None and (not isinstance(gid, int) or gid < 0): + return make_error("Invalid gid: must be a non-negative integer") + + manager = _b().get_worktree_manager() + worktrees = {} + errors = [] + + for repo in repos: + # Extract repo name from owner/repo format + if "/" in repo: + repo_name = repo.split("/")[-1] + else: + repo_name = repo + + # Pre-bind so the except clauses below can reference + # effective_branch even if resolve_default_branch raises (e.g., + # OSError if the git binary is missing). See #2186 review + # feedback — previously hoisted out of the try, which let + # resolve_default_branch failures bypass the per-repo errors[] + # safety net entirely. + effective_branch = base_branch + try: + # Resolve the default branch per-repo when no explicit base is given. + # This ensures repos with non-standard default branches (e.g., master) + # are handled correctly. See #860. + effective_branch = base_branch or manager.resolve_default_branch(repo_name) + info = manager.create_worktree( + repo_name=repo_name, + container_id=container_id, + base_branch=effective_branch, + uid=uid, + gid=gid, + assigned_branch=assigned_branch, + repo_slug=repo, + push_branch=push_branch, + ) + # Translate container path to host path for egg launcher mount sources. + # Key by the full ``owner/repo`` slug (#3393 slice-3, operator + # ruling #6) so two repos with the same short name under different + # owners (``ownerA/foo`` vs ``ownerB/foo``) no longer collide on a + # single map entry. When the caller passed a bare repo name (no + # ``/``), ``repo`` equals ``repo_name`` so bare-name callers are + # unaffected. The on-disk worktree directory (and the container + # mount target) stays the bare ``repo_name`` — only the map KEY + # carries the owner prefix. + worktrees[repo] = _b().translate_to_host_path(str(info.worktree_path)) + except (ValueError, RuntimeError) as e: + # Capture full traceback so operators can diagnose without + # re-instrumenting the gateway. See #2186. + logger.exception( + "worktree_create per-repo failure", + repo_name=repo_name, + container_id=container_id, + base_branch=effective_branch, + assigned_branch=assigned_branch, + error_type=type(e).__name__, + ) + errors.append(f"{repo_name}: {e}") + except Exception as e: + logger.exception( + "worktree_create unexpected per-repo failure", + repo_name=repo_name, + container_id=container_id, + base_branch=effective_branch, + assigned_branch=assigned_branch, + error_type=type(e).__name__, + ) + errors.append(f"{repo_name}: unexpected error - {e}") + + if errors and not worktrees: + # Total failure: surface aggregate errors in logs + audit, then + # best-effort clean up the empty container directory so retries + # aren't blocked by stale state. See #2186. + logger.error( + "worktree_create failed for all repos", + container_id=container_id, + errors=errors, + ) + _b().audit_log( + "worktrees_create_failed", + "worktree_create", + success=False, + details={ + "container_id": container_id, + "errors": errors, + }, + ) + _b()._cleanup_empty_container_dir(container_id) + return make_error( + "Failed to create any worktrees", + status_code=500, + details={"errors": errors}, + ) + + _b().audit_log( + "worktrees_created", + "worktree_create", + success=True, + details={ + "container_id": container_id, + "repos": list(worktrees.keys()), + "errors": errors, + }, + ) + + return make_success( + "Worktrees created", + { + "worktrees": worktrees, + "errors": errors if errors else None, + }, + ) + + +def worktree_delete() -> tuple[Response, int] | Response: + """ + Delete worktrees for a container. + + Called by the egg launcher when a container exits. Removes the worktrees + and associated branches. + + Request body: + { + "container_id": "egg-xxx-yyy", + "force": false # optional, force remove even with uncommitted changes + } + + Returns: + { + "success": true, + "message": "Worktrees deleted", + "data": { + "deleted": ["repo1", "repo2"], + "warnings": ["repo1: had uncommitted changes"] + } + } + """ + data = request.get_json() + if not data: + return make_error("Missing request body") + + container_id = data.get("container_id") + force = data.get("force", False) + + if not container_id: + return make_error("Missing container_id") + + manager = _b().get_worktree_manager() + + # Get list of worktrees for this container + worktree_dir = manager.worktree_base / container_id + if not worktree_dir.exists(): + return make_success("No worktrees to delete", {"deleted": []}) + + deleted = [] + errors = [] + warnings = [] + + # Iterate through worktree directories + for repo_dir in list(worktree_dir.iterdir()): + if not repo_dir.is_dir(): + continue + + repo_name = repo_dir.name + + try: + result = manager.remove_worktree( + container_id=container_id, + repo_name=repo_name, + force=force, + ) + + if result.success: + deleted.append(repo_name) + if result.warning: + warnings.append(f"{repo_name}: {result.warning}") + elif result.uncommitted_changes and not force: + errors.append(f"{repo_name}: has uncommitted changes (use force=true)") + elif result.error: + errors.append(f"{repo_name}: {result.error}") + else: + errors.append(f"{repo_name}: removal failed") + except Exception as e: + errors.append(f"{repo_name}: unexpected error - {e}") + + _b().audit_log( + "worktrees_deleted", + "worktree_delete", + success=True, + details={ + "container_id": container_id, + "deleted": deleted, + "errors": errors, + }, + ) + + return make_success( + "Worktrees deleted", + { + "deleted": deleted, + "errors": errors if errors else None, + "warnings": warnings if warnings else None, + }, + ) + + +def worktree_list() -> tuple[Response, int] | Response: + """ + List all active worktrees. + + Returns information about all worktrees managed by the gateway. + """ + manager = _b().get_worktree_manager() + worktrees = manager.list_worktrees() + return make_success("Worktrees listed", {"worktrees": worktrees}) + + +_worktree_prune_lock = threading.Lock() + + +_SLICE_WORKTREE_SUFFIX_RE = re.compile(r"^-slice-[0-9]+-[a-z_]+$") + + +def _derive_worktree_anchor_ids(sessions: list[dict[str, Any]]) -> set[str]: + """Return the set of worktree directory names implied by active sessions. + + Session ``container_id`` is the k8s Job name / Docker container name + (e.g. ``egg-agent-issue-1758-again-coder``), but per-agent worktrees + on disk are named after the orchestrator-assigned + ``agent_worktree_id`` of the form ``{pipeline_id}-{agent_role}`` and + the pipeline-level worktree is just ``{pipeline_id}``. Without this + derivation, ``cleanup_orphaned_worktrees`` treats every per-agent + worktree as an orphan, which wiped live pipelines' worktrees during + gateway startup cleanup (#1874). + + Slice-scoped per-agent worktrees (``{pipeline_id}-slice-{N}-{role}``, + introduced in #2403) carry no slice context on the session, so for + each live pipeline we additionally scan the worktree base for + matching directories and add them to the anchor set. Without this + scan, slice-scoped agent worktrees with unpushed local commits are + wiped during gateway startup cleanup — silently destroying the work + that ``salvage_agent_commits`` was designed to recover (#2463). + """ + anchors: set[str] = set() + pipeline_ids: set[str] = set() + for session_info in sessions: + pipeline_id = session_info.get("pipeline_id") + agent_role = session_info.get("agent_role") + if pipeline_id: + anchors.add(pipeline_id) + pipeline_ids.add(pipeline_id) + if agent_role: + anchors.add(f"{pipeline_id}-{agent_role}") + + if pipeline_ids: + try: + entries = list(_b().WORKTREE_BASE_DIR.iterdir()) + except OSError: + entries = [] + for entry in entries: + try: + if not entry.is_dir(): + continue + except OSError: + continue + name = entry.name + for pid in pipeline_ids: + if not name.startswith(pid): + continue + suffix = name[len(pid) :] + if _SLICE_WORKTREE_SUFFIX_RE.match(suffix): + anchors.add(name) + break + return anchors + + +def _container_ids_from_sessions(sessions: list[dict[str, Any]]) -> set[str]: + """Return container IDs and worktree anchor IDs from session dicts. + + Each session contributes its own ``container_id`` plus the derived + per-agent (``{pipeline_id}-{agent_role}``), pipeline-level + (``{pipeline_id}``), and slice-scoped + (``{pipeline_id}-slice-{N}-{role}``) worktree anchor IDs so that + cleanup never wipes a live pipeline's worktrees (#1874, #2463). + """ + ids: set[str] = set() + for session_info in sessions: + cid = session_info.get("container_id") + if cid: + ids.add(cid) + ids |= _b()._derive_worktree_anchor_ids(sessions) + return ids + + +def _collect_active_container_ids() -> set[str]: + """Return the best-effort set of container IDs that back live sessions. + + Mirrors the startup-cleanup logic at module level so the prune route + never issues a sweep with an empty active set (which would otherwise + treat every worktree as an orphan — see #1759 review). + + Consults: + + 1. Persisted sessions via :func:`get_session_manager` (primary source + of truth — survives gateway restarts). Each session contributes + its own ``container_id`` plus the derived per-agent and + pipeline-level worktree anchor IDs so that cleanup never wipes a + live pipeline's worktrees (#1874). + 2. ``docker ps`` when Docker is reachable (safety net for sessions + that outlive the session-manager snapshot). + + Failures in either step degrade silently so the prune still runs — + the worst case is that a genuinely orphaned dir is preserved, which + the next scheduled prune will clean up. + """ + active_container_ids: set[str] = set() + try: + session_manager = _b().get_session_manager() + sessions = session_manager.list_sessions() + active_container_ids |= _b()._container_ids_from_sessions(sessions) + except Exception as exc: + logger.warning( + "prune: session-manager active-container lookup failed", + error=str(exc), + ) + try: + active_container_ids |= _b().get_active_docker_containers() + except Exception as exc: + # Non-fatal: on k3s there is no dockerd reachable from the + # orchestrator's sidecar, and that is fine. + logger.debug( + "prune: docker active-container probe unavailable", + error=str(exc), + ) + return active_container_ids + + +def worktrees_prune() -> tuple[Response, int] | Response: + """ + Run ``git worktree prune`` across every repo and sweep orphan dirs + under the worktree base. + + Request body:: + + {"dry_run": bool = true} + + When ``dry_run`` is true, returns the set of orphan directories + that would be removed but does not mutate the filesystem. When + false, removes them using the existing ``cleanup_orphaned_worktrees`` + helper. The active-container set is derived from the session + manager (plus an opportunistic ``docker ps`` fallback), and worktrees + anchored to a non-terminal pipeline (per the orchestrator) are + preserved even with no live container — a pipeline parked at a HITL + gate has neither (#3070). Returns 503 when pipeline liveness cannot + be verified. + + Proxied from the orchestrator's + ``/api/v1/deployment/prune-worktrees`` endpoint (#1759). + """ + data = request.get_json(silent=True) or {} + dry_run = bool(data.get("dry_run", True)) + + manager = _b().get_worktree_manager() + + # Serialize all prune activity — git operations on the same repo + # must not interleave even if two callers hit this endpoint + # concurrently. + if not _b()._worktree_prune_lock.acquire(timeout=60): + return make_error("Another worktree prune is in progress", status_code=409) + try: + # Pipeline liveness is required, not best-effort: a parked pipeline + # has no containers or sessions, so the container-derived set alone + # would mark its worktree an orphan (#3070). This endpoint is proxied + # from the orchestrator, so it is normally up; refuse rather than + # sweep blind if it cannot answer. + active_pipeline_ids = _b().fetch_active_pipeline_ids() + if active_pipeline_ids is None: + return make_error( + "Cannot verify pipeline liveness (orchestrator unreachable); " + "refusing to prune worktrees", + status_code=503, + ) + + active_container_ids = _b()._collect_active_container_ids() + git_prune_report = manager.git_worktree_prune_all() + orphan_dirs = manager.list_orphan_worktree_dirs( + active_containers=active_container_ids, + active_pipeline_ids=active_pipeline_ids, + ) + + removed_count = 0 + removed_paths: list[str] = [] + if not dry_run and orphan_dirs: + removed_count = manager.cleanup_orphaned_worktrees( + active_containers=active_container_ids, + active_pipeline_ids=active_pipeline_ids, + ) + # Any orphan we enumerated that no longer exists on disk + # was removed by the helper. + for path in orphan_dirs: + try: + if not Path(path).exists(): + removed_paths.append(path) + except OSError: + pass + + _b().audit_log( + "worktrees_pruned", + "worktrees_prune", + success=True, + details={ + "dry_run": dry_run, + "git_worktree_prune": git_prune_report, + "orphan_dirs_count": len(orphan_dirs), + "active_containers_count": len(active_container_ids), + "active_pipelines_count": len(active_pipeline_ids), + "removed_count": removed_count, + }, + ) + + return make_success( + "Worktree prune complete", + { + "dry_run": dry_run, + "git_worktree_prune": git_prune_report, + "orphan_dirs": orphan_dirs, + "active_containers_count": len(active_container_ids), + "active_pipelines_count": len(active_pipeline_ids), + "removed_count": removed_count, + "removed_paths": removed_paths, + }, + ) + finally: + _b()._worktree_prune_lock.release() diff --git a/gateway/tests/conftest.py b/gateway/tests/conftest.py index 4fc0f7f144..5171ab9241 100644 --- a/gateway/tests/conftest.py +++ b/gateway/tests/conftest.py @@ -10,7 +10,6 @@ import os import sys -from importlib.machinery import ModuleSpec from pathlib import Path from types import ModuleType @@ -380,62 +379,32 @@ def _load_module_with_replaced_imports( GATEWAY_DIR / "mem_trace.py", ) -# gateway imports from all -gateway = _load_module_with_replaced_imports( +# gateway.py became the gateway/gateway/ sub-package in #3312 slice-18 +# (10,648 lines, over the byte cap). Like git_client/ and worktree_manager/ +# above, the single-file _load_module_with_replaced_imports loader cannot exec +# a package whose __init__/submodules use ``from .._sibling import`` / +# ``from ._submodule import`` imports, so load it via an explicit spec with +# submodule_search_locations. The package is registered under BOTH +# ``gateway`` (the flat top-level name the whole test suite imports) and +# ``gateway.gateway`` (the dotted path production uses and tests patch, e.g. +# ``patch("gateway.gateway.get_session_manager")``) so both resolve to the one +# barrel object. The barrel's own ``from .. import`` lines raise +# ImportError at the top level in flat mode and fall through to the paired +# ``from import`` absolutes, which resolve against the flat sibling +# modules this conftest already registered above. submodule_search_locations +# lists the package dir first (so ``from ._submodule import`` resolves) and +# GATEWAY_DIR second (so ``gateway.tests`` still resolves for pytest +# collection, and grimp's find_spec("gateway") returns a real package spec). +_gateway_pkg = GATEWAY_DIR / "gateway" +_gateway_pkg_spec = _importlib_util.spec_from_file_location( "gateway", - GATEWAY_DIR / "gateway.py", - import_replacements={ - "from ._module_loader import": "from _module_loader import", - "from .agent_restrictions import": "from agent_restrictions import", - "from .anthropic_credentials import": "from anthropic_credentials import", - "from .artifact_api import": "from artifact_api import", - "from .auth import": "from auth import", - "from .contract_api import": "from contract_api import", - "from .git_client import": "from git_client import", - "from .github_client import": "from github_client import", - "from .phase_api import": "from phase_api import", - "from .phase_filter import": "from phase_filter import", - "from .policy import": "from policy import", - "from .private_repo_policy import": "from private_repo_policy import", - "from .repo_parser import": "from repo_parser import", - "from .session_manager import": "from session_manager import", - "from .rate_limiter import": "from rate_limiter import", - "from .repo_visibility import": "from repo_visibility import", - "from .routing_policy import": "from routing_policy import", - "from .worktree_manager import": "from worktree_manager import", - "from .jira_client import": "from jira_client import", - "from .jira_credentials import": "from jira_credentials import", - "from .jira_policy import": "from jira_policy import", - "from .jira_search import": "from jira_search import", - "from .confluence_client import": "from confluence_client import", - "from .confluence_credentials import": "from confluence_credentials import", - "from .confluence_policy import": "from confluence_policy import", - "from .confluence_search import": "from confluence_search import", - "from .mode_gate import": "from mode_gate import", - }, + _gateway_pkg / "__init__.py", + submodule_search_locations=[str(_gateway_pkg), str(GATEWAY_DIR)], ) - -# Loading gateway.py as sys.modules["gateway"] above replaces the real -# gateway package; without __path__, pytest's collector (with -# consider_namespace_packages=true) cannot walk into gateway.tests.* -# and raises "module 'gateway' has no attribute '__path__'" for every -# test file under gateway/tests/. Point __path__ at the gateway dir so -# the namespace subpackage gateway.tests resolves correctly. -gateway.__path__ = [str(GATEWAY_DIR)] - -# Set __spec__ so importlib.util.find_spec("gateway") returns a valid -# package spec instead of raising "gateway.__spec__ is None". Tools -# like grimp (used by scripts/select_tests/) call find_spec to -# locate the gateway package on disk; without this, they fail at -# graph build time once the gateway tests have populated sys.modules. -_gateway_spec = ModuleSpec( - name="gateway", - loader=None, - origin=str(GATEWAY_DIR / "__init__.py"), - is_package=True, -) -_gateway_spec.submodule_search_locations = [str(GATEWAY_DIR)] -gateway.__spec__ = _gateway_spec +gateway = _importlib_util.module_from_spec(_gateway_pkg_spec) +sys.modules["gateway"] = gateway +sys.modules["gateway.gateway"] = gateway +_gateway_pkg_spec.loader.exec_module(gateway) # Also load the __init__.py to prevent pytest from trying to import it # and failing on relative imports diff --git a/scripts/file-size-allowlist.yaml b/scripts/file-size-allowlist.yaml index 00277cbec9..7bf13b0a2e 100644 --- a/scripts/file-size-allowlist.yaml +++ b/scripts/file-size-allowlist.yaml @@ -22,7 +22,5 @@ caps: files: orchestrator/routes/pipelines.py: issue: "2248" - gateway/gateway.py: - issue: "2248" orchestrator/concurrent_executor.py: issue: "3498"