diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6caee3b3..d73ede4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,7 +161,18 @@ jobs: # in lexical (file-name) order — which matches the numbered # migration convention. Newline-separated so the shell loop can # iterate cleanly. + # + # `middleware/migrations` is the core runtime domain — the shared + # schema for the main app's features. It is self-contained: no + # cross-domain FKs and no extension dependencies, so its position + # in this list is not load-bearing. + # + # Still uncovered (each needs its own audit before enabling): + # middleware/src/conductor/migrations, + # middleware/src/services/graph/migrations, + # middleware/packages/harness-memory-postgres/src/migrations. MIGRATION_DOMAINS: | + middleware/migrations middleware/packages/harness-knowledge-graph-neon/src/migrations middleware/src/auth/migrations middleware/src/plugins/routines/migrations diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4d81791a..df80382f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,516 @@ entry. See `CONTRIBUTING.md` § Releases & changelog. ## [Unreleased] +### Fixed — `per_user` MCP delegation was unreachable from chat + +- Migration `0031` made delegation explicit per MCP server and gave new servers a + fail-closed `per_user` default. `resolveMcpUserKey` reads + `turnContext.current()?.mcpUserKey` — but **the only thing that ever set it was + the operator discover route.** `routes/chat.ts` did not so much as import + `turnContext`. Every newly created `per_user` server was therefore dead from + chat out of the box: no token sent, the audit row recording the literal + `unresolved`, and the turn failing closed. Existing installs were masked only + because `0031` backfills token-holding servers to `service`. +- Both HTTP chat entries now open a turn scope carrying `mcpUserKey`. The + streaming entry uses `turnContext.runGenerator`, not `enter`: `enterWith` binds + to the async resource executing at that instant, and an async generator resumes + in the caller's context, so the identity would be gone by the orchestrator's + first yield — before any tool, and therefore before any MCP call, runs. +- The value is `sessionIdentity(req)` (`session.sub || session.email`), extracted + from `routes/agentBuilder.ts` into `src/auth/sessionIdentity.ts`. Deliberately + **not** `resolveUserId(req)`, which falls through to the client-sent + `x-user-id` header — keying MCP tokens on a client-controlled header would let + any caller act as any user. When nothing resolves, `mcpUserKey` stays unset and + a `per_user` server fails closed exactly as intended; there is no fallback. +- Channel turns set `mcpUserKey` inside the orchestrator from the already-resolved + `resolvedOmadiaUserId`, gated on `channelIdentity` — which only the dispatcher + mints, from the adapter's authenticated `userRef`, so it is server-attested end + to end. ⚠️ **Known limit:** channel turns key on the canonical omadia uuid while + `/authorize` stores tokens under the session-shaped key, so an affected user + still fails closed rather than reaching their server. Closing that needs a new + method on the `KnowledgeGraph` contract. Narrower than it sounds: a `per_user` + token can only exist for someone who completed `/authorize`, which requires a + session, so a channel-only user has no token and failing closed is correct. + +### Fixed — migration `0031` built neither of its guards reliably + +- The CHECK guard looked up `pg_constraint` by `conname` alone. `conname` is + unique per `(connamespace, conrelid)`, not cluster-wide, so a same-named + constraint in **any** other schema made the guard true and the `ALTER TABLE` was + silently skipped — the migration did not build the constraint it claims to. Now + anchored on `conrelid = 'mcp_servers'::regclass`. +- The backfill guard hardcoded `to_regclass('public.mcp_oauth_tokens')` in a file + that is otherwise entirely unqualified, so wherever the domain is applied outside + `public` it answered about a table the statement never touches. Demonstrated on a + database with an empty `public`: the old guard left an operator-token server on + `per_user`, losing its grandfathering and breaking it fail-closed. +- The backfill test previously **rewrote** the migration to make it apply; it now + applies verbatim, with a guard that fails if a schema-qualified reference is ever + reintroduced, plus the assertion the suite had dropped as a known flake. + +### Fixed — the middleware suite had no per-test timeout + +- `--test-timeout=120000`. Previously unset, so Node's default of `Infinity` + applied and a hung test burned the CI job's 15-minute wall with no attribution. + Note the ceiling is **per file**, not per leaf — a file whose total exceeds it is + killed as a unit — so the value is sized on the slowest file (18.4 s), not the + slowest test (7.8 s). `web-ui` needs no change; vitest already bounds at 5 s. + +### Added — operator surface for public MCP key bindings + +- The public MCP endpoint's authorization is driven entirely by rows in + `public_mcp_key_bindings`, and there was **no way to create one** except + hand-written SQL — the endpoint was inert as shipped. A Public API keys tab in + the MCP Control Center now lists, creates and revokes bindings. +- The public endpoint's dependency bag is unchanged and still receives the + read-only store: it gains no write path to its own authorization table. The + admin path validates through the same `normalizeBindingRow` the enforcement path + uses, so the two cannot drift. Revoke parks the row rather than deleting it. +- **Revoke is sticky.** A cross-vendor review found that saving a binding + re-enabled it: an omitted `enabled` was defaulted to `true` and written over the + stored value, so any later save — a stale browser tab, a second operator, a + config replay, or this pane's own form, which does not round-trip the field — + silently handed a revoked key its whole allowlist back. An absent `enabled` now + preserves the stored flag (a genuinely new row still starts enabled), and + un-parking is an explicit act: `POST /:keyId/restore`, or an explicit + `enabled: true` on the upsert. The pane grew a confirmed **Restore access** + button so the stricter server does not strand an operator in psql. +- `POST /` answers **200** for a row it replaced and keeps 201 for one it created + — "Created" is the operator's only per-request signal that they landed on a + binding somebody else had already configured, or parked. +- `writeRateLimitPerMinute` and `enabled` are type-checked rather than coerced. A + JSON `null` reached `Number(null)` → `0`, a valid write budget, so a client + sending `null` to mean "use the default" got an integration that authenticates, + resolves its binding, and is throttled to nothing on every write while the UI + showed write tools listed. `[]`, `false` and `""` coerced identically; `true` + became 1. Bad values are now a 400. +- 500 bodies no longer carry `String(err)`. pg errors name tables, columns and + constraints and sometimes the connection host, and those bodies land in browser + devtools and UI logs; the detail is logged server-side instead. + +### Fixed — raw NUL bytes made ripgrep silently truncate eight source files + +- Fifteen literal `0x00` bytes, used as composite map-key separators, are now + written as `\0`. Provably a no-op — none is followed by an ASCII digit, the only + case where the escape would change meaning. Behaviour is bit-identical; what + changes is that `rg` no longer classifies these files as binary and stops + searching partway through, silently truncating every audit that crosses them. + +### Fixed — the MCP input-replay path put raw tool output on the LLM wire + +- Privacy Shield v4's boundary is **server ↔ LLM provider**, not server ↔ browser: + `internToolResultV4` returns an identity-free digest for the `tool_result` block + while the real rows stay server-side behind a `datasetId`, and the browser + legitimately receives real values (`PrivacyRenderedAnswer.text`, highlighted via + `maskedValues` so the user can see what the server resolved). +- The replay that runs after a user answers an MCP input card called + `mcpManager.callTool` **directly** rather than going through `dispatchTool`, so + the result was never interned — and was then interpolated verbatim into the note + folded into the turn's ingested text. A replayed HR or accounting tool returning a + personnel row sent that row to the model in cleartext, where the identical tool on + an ordinary turn would have yielded only a digest. +- The comment above the interpolation shows this was a near-miss rather than a + decision: it reasons explicitly about the LLM wire, but only about the user's + typed values, and overlooks the tool result two lines below. Found by + cross-vendor review, live in any deployment with a graph pool. + +### Known limitation — #547 structured content still has no renderer + +- `emitStructured` fires inside `McpManager.callTool`, beneath every dispatcher, so + the sidecar is not interned. `middleware/test/mcpStructuredOutputPrivacy.test.ts` + pins that mechanism over a real MCP socket, and confirms `outputSchema` and + `turnId` already reach the sidecar. +- **This is not a leak to the browser** — an earlier reading of it as one was + corrected by cross-vendor review; the browser is the trusted side. The renderer is + deferred for two ordinary reasons instead: it is a full-stack change across eight + web-ui files on an already-large PR, and the sidecar bypasses Privacy Shield's + receipt and dataset *accounting* even where masking is not owed, which wants a + decision before anything renders from it. + +### Added — public, stateless MCP endpoint (`POST /api/v1/mcp`) + +- omadia can now expose **its own tools** over a stateless Streamable-HTTP MCP + server so an external MCP client (Claude Desktop, an agent framework, your own + service) can call them with an API key instead of driving the operator UI. + External-consumer documentation: `middleware/src/mcp/README.md`. +- **Stateless by construction.** `sessionIdGenerator: undefined`, no + `initialize` handshake required, no `Mcp-Session-Id` ever issued, and a fresh + `Server` + transport pair per request torn down in a `finally`. That is what + makes the endpoint horizontally scalable — any instance can answer any + request. `POST` only; a non-POST gets `405` (a per-request transport leaks on + `GET`, because an SSE stream never ends and the teardown never runs). +- ⚠️ **DARK BY DEFAULT.** `PUBLIC_MCP_ENABLED=false` mounts **no router at + all**. This is the highest-blast-radius surface in the MCP cluster — an + internet-facing route that reaches the tool layer, including WRITE tools — so + not mounting is a stronger guarantee than mounting something that answers 403. + +#### Authorization — default-deny at four independent layers + +- New scopes on `@omadia/api-key-auth`: `mcp:list`, `mcp:invoke`, and per-tool + `mcp:write:`. +- **`mcp:invoke` is not sufficient for a write**, and **`*` (`WILDCARD_SCOPE`) + does not grant any write.** The wildcard exclusion lives inside `hasScope` + itself, so no caller can reach a permissive matcher by accident. The bare + two-segment `mcp:write` is rejected at key creation: it would validate, + persist, and grant nothing — indistinguishable from a revoked key. +- **Allowlist per KEY, not per server** (`public_mcp_key_bindings`, migration + `0033`). A key reaches exactly one **agent** and exactly the tool names listed + on it. A key with no binding authenticates and reaches **zero** tools, which + is how integration-backed and write-capable tools (Odoo, Microsoft 365, + Confluence) stay out of reach by default — nothing is included until an + operator names it. +- `tools/list` is filtered per caller to exactly the set the key could + successfully **call**. A tool name the caller cannot invoke is itself a + disclosure, and a non-allowlisted tool is indistinguishable from a + nonexistent one. +- **Write capability is the union** of the tool's own `writeCapabilities` + declaration (`isWriteCapableTool`) and the operator's `write_tools` list, so a + mistake in either direction fails toward "treat it as a write". + +#### Privacy — fails CLOSED for public callers + +- The shared dispatch path masks PII at chat-path **parity**, which includes two + behaviours that are wrong for an untrusted caller. This endpoint overrides + both, without changing the chat path: + - **Masking failure refuses the call** instead of returning the raw result. + - **An operator's per-plugin privacy bypass does not extend** to a public + caller. + - **Intern-exempt tools** (`memory`, `read_attachment`, …) — whose results the + Privacy Shield deliberately hands over in clear — are **never servable** + here, whatever an operator configures. +- With **no privacy provider installed**, tool calls are refused and say why + (`tools/list` still works). `PUBLIC_MCP_ALLOW_WITHOUT_PRIVACY_MASKING=true` is + the documented escape hatch for an install whose allowlisted tools provably + carry no personal data. + +#### Limits, audit, and what idempotency does NOT promise + +- 8 MB request body, 30 s per-tool timeout, endpoint-wide concurrency ceiling, + and a **separate, tighter rate-limit budget for writes** — heavy reading + cannot fund a write burst. +- One `mcp_call_log` row per call **including every refusal**, with + `caller_kind = 'api_key'` (new in migration `0033`) and the acting identity + `apikey:`. The acting identity is now **visible in the admin MCP call-log + UI** for every row, not just for public calls — it had been recorded but never + surfaced. +- `_meta.idempotencyKey` is honoured for write-capable tools but is + **advisory**: process-local, ~15 minute window, so two instances behind a load + balancer can both execute. It is retry safety, **not distributed + exactly-once** — see the README before relying on it. + +### Added — MCP Client ID Metadata Documents, as a third client-acquisition mode + +- omadia can now identify itself to an MCP authorization server by a **Client ID + Metadata Document** — an https `client_id` the server dereferences — served at + `GET /.well-known/omadia-mcp-client`. This removes the app-registration step at + MCP-native brokers that support it. +- Client acquisition is now an explicit ordered chain: + `stored → cimd → dcr (deprecated, warns) → manual`. CIMD is attempted only when + the authorization server advertises `client_id_metadata_document_supported` + **and** the document is verifiably reachable. +- **Nothing is deprecated on omadia's side.** Dynamic Client Registration keeps + working and merely logs a deprecation notice (the MCP spec's sunset is a + 12-month clock). The manual OAuth client stays **permanently first-class**: it + is the protocol-correct path for Microsoft Entra ID and Okta, neither of which + supports CIMD. +- ⚠️ **Deployment requirement — CIMD needs INBOUND HTTPS reachability.** The + identity provider must fetch the document from omadia, which is strictly + stronger than the outbound-redirect-only requirement every other mode has. Set + `FLOW_PUBLIC_BASE_URL` to an https origin reachable from the internet. + Deliberately *not* derived from `PUBLIC_BASE_URL`, whose `localhost` default is + exactly the shape that cannot work. +- **A firewalled or air-gapped install degrades cleanly, it does not break.** The + metadata endpoint answers **501 with an actionable message** rather than 500, + the acquisition chain falls through to the manual client, and the MCP Control + Center explains which mode a server is on plus why CIMD is unavailable when it + is. A byte5-hosted metadata relay is **not** offered by default — it would make + every customer's `client_id` identify byte5 to that customer's IdP. +- Migration `0032_mcp_oauth_cimd.sql` adds `'cimd'` to the + `mcp_oauth_clients.registered_via` CHECK set and a `client_metadata_url` + column. The CHECK is widened, not dropped — an unknown mode is still rejected. +- Security: the metadata-URL probe reuses the existing `assertPublicHttpsUrl` + SSRF guard (no second validator), a CIMD client is public by construction so no + secret is stored, the document carries no secret, and the W0-1 RFC 9207 `iss` + validation plus flow-bound endpoint pinning are untouched. `mcp_oauth_flows` + TTL pruning was verified to actually exist in both places it is claimed. +- Rationale, rejected options, and the full deployment note: + [ADR-0006](adr/0006-mcp-client-id-metadata-documents.md). +- Note on issue #546: its premise that the registry "supports only static headers + with `secretRef`" was incorrect — the provider-agnostic OAuth 2.1 + PKCE stack + shipped in epic #459 W9. This release is a delta on that stack. +### Added — MCP tools can ask the user for input mid-call (MRTR `input_required`, #544) + +- An MCP server that answers `tools/call` with + `resultType: "input_required"` plus `inputRequests` now gets a real input + form instead of a failed tool call. The turn ends, the channel renders the + fields, and the user's answer replays the parked call automatically. + `resultType` and `inputRequests` are read off the **shipped SDK 1.29.0** — + no version bump and no dependency on the `@modelcontextprotocol/*@2.0.0` + family (#540). +- **Two turns, not a suspended one.** MRTR imagines the client retrying the + *original* request with the call still in flight. omadia has no per-turn + suspend/resume store — `turnContext` is an `AsyncLocalStorage` whose + lifetime is the turn — and parking a turn mid-tool-loop would hold the HTTP + or Teams connection open past every proxy idle timeout. So the feature rides + the existing `ask_user_choice` short-circuit: the turn ends, and the answer + arrives as a fresh turn that re-calls the tool with + `{...originalArgs, inputResponses}`. + **Accepted limitation:** the replay is a NEW `tools/call` in a LATER turn + against a possibly reconnected transport. For a stateless HTTP server that + is indistinguishable from the retry MRTR describes; for a **stdio server + holding process state tied to the original in-flight call** it is not — that + state may be gone and the server sees a fresh call rather than a + continuation. Servers needing true continuation semantics are out of scope + until omadia has a real turn suspend/resume store. +- **The card always names the asking server.** An MCP server can now make + omadia display arbitrary prose and collect arbitrary free text + mid-conversation, so a card that hid the asker would let a hostile server + phish credentials behind omadia's own chrome. Every surface attributes the + request: the web-ui form, the plain-text fallback for channels without form + support, and the session-log line. Server-supplied prose is rendered quoted + and attributed, never as omadia's own copy, and `secret` fields say plainly + that the value still reaches the server as entered. +- A parked record is bound to `{userId, sessionId, correlationId}` and is + replayable by that triple only. `sessionScope` alone is deliberately not a + key: `resolveScope` returns the literal `'http-default'` for unscoped HTTP + turns, which was the live cross-user hole in #445. Records are single-use, + TTL-bounded (15 min), and a second `input_required` raised *by* a replay is + capped rather than bouncing the user indefinitely. +- The MCP call audit gains a three-valued `outcome` + (`ok` | `fail` | `input_required`). A parked call previously had nowhere + honest to go: `ok: false` would put a phantom failure in front of operators + debugging a healthy server, and a bare `ok: true` would claim a result that + was never delivered. `ok` keeps its narrower meaning ("did not fail") and + the finer truth gets its own field. +- When both an `ask_user_choice` card and an MCP input request are pending in + the same tool batch, **the choice card wins** — deterministically, not by + dispatch order. A model that asked its own clarifying question has decided + it does not yet understand the request, so collecting server-specific field + values first would answer the wrong question. The MCP record is not + discarded; it stays replayable until its TTL. +- Not included: omadia acting as an MCP *server* and signalling + `input_required` to its own clients. That needs a `ToolDispatchService` + result-type widening touching every plugin dispatch handler, and is a + separate issue. + +### Fixed — `turnContext` is empty inside tool handlers on the streaming path + +- Found while building #544. `Orchestrator.chatStream` establishes the turn + context with `turnContext.enter()` (`AsyncLocalStorage.enterWith`) inside an + async generator, which does **not** propagate into the generator's own + continuations — so `turnContext.current()` is `undefined` in every tool + handler on every streaming turn, including the web-ui path. Verified with a + probe against both entry points. +- #544 does not depend on it (the parked-record owner is bound from the turn + input the orchestrator holds directly), and `userId` + `sessionScope` are now + populated on both entry points. The broader consequences for + `mcpCallerKind` / `mcpUserKey` audit attribution on streaming turns are + **not** addressed here and want their own issue. + +### Fixed — the CI schema job never applied `middleware/migrations` + +- `MIGRATION_DOMAINS` in `.github/workflows/ci.yml` listed five domains and + omitted `middleware/migrations` — the core runtime domain holding `0001` + through `0030`. Every migration there had therefore shipped without ever + being applied, or re-applied for the idempotency check, against a real + Postgres in CI: the whole MCP schema (`0003` agent-builder graph, `0008` + tool verdicts, `0009` call log, `0010`/`0013` registries, `0012`/`0014` + grants, `0015`/`0016` OAuth 2.1 + PKCE, `0017`–`0020`) and every + dev-platform migration (`0022`–`0030`). The gap was suspected during #330 + and is now closed; the domain is applied first, ahead of the knowledge-graph + domain. +- **No latent schema defect was exposed.** All 30 files apply and re-apply + cleanly against `pgvector/pgvector:pg16`, in both possible domain + orderings, and additionally with rows present. The domain is fully + self-contained: no cross-domain foreign keys, no shared object names with + the other five domains, and no extension dependency at all + (`gen_random_uuid()` is core since pg13). Verified locally with a + reproduction of the CI job before the workflow change was pushed. +- The workflow comment now records the three domains that remain uncovered + (`middleware/src/conductor/migrations`, + `middleware/src/services/graph/migrations`, + `middleware/packages/harness-memory-postgres/src/migrations`), each of which + needs its own audit before being enabled. + +### Added — first pg coverage for the MCP schema + +- `middleware/test/mcpRegistrySchema.pg.test.ts` — no pg test touched MCP + before this (only `memoryStoreConformance`, `pluginVerdictStore` and + `skillLifecycleStore` existed). Asserts the registry seed and catalog-kind + backfill (`0010` + `0013`, including that `0013`'s `UPDATE` actually lifts + the official registry off the `generic` column default), the `kind` / + `auth_kind` / `source` / `registered_via` CHECK sets, marketplace + provenance defaults with `ON DELETE SET NULL` detaching an imported server + from a deleted catalog, the `0014` partial unique index on top-level MCP + grants (and that it leaves native grants alone), and the `0015`/`0016` + OAuth surface — authorize-time endpoint pinning plus token/flow cascade on + server delete. +- A second suite covers what the CI gate structurally cannot: the CI + idempotency check re-applies against an **empty** database, so it can never + catch a migration that only breaks once rows exist. That suite re-applies + all 30 files with MCP rows in place. It runs against a dedicated schema on + a pinned connection with `public` off the `search_path`, so the migrations + build a private copy of the domain: re-running `0001`/`0003` drops and + recreates the NOTIFY triggers and takes ACCESS EXCLUSIVE on shared tables, + which must not happen underneath a concurrently running suite. A scratch + *database* isolates just as well but `CREATE`/`DROP DATABASE` is a + cluster-wide operation — it stalled the dev-platform pg suites long enough + to cancel 29 of their tests, so the schema is the cheaper boundary. The + test asserts the isolation itself, since a leaked `search_path` would make + every later assertion pass vacuously. +- Both suites skip when no test Postgres is reachable, and scope every row + they write to a `w04-mcp-` tenant prefix, matching the existing pg-suite + convention. They share one capped pool: the runner executes test files + concurrently and ~16 other pg suites each hold a default-sized (max 10) + pool, so an uncapped extra pool in one file exhausts `max_connections` and + cancels an unrelated suite mid-run. +### Security — MCP OAuth: issuer binding, explicit delegation, refresh race (W0-1) + +Three live defects in the MCP OAuth path, one migration +(`middleware/migrations/0031_mcp_oauth_iss_delegation.sql`). + +- **RFC 9207 `iss` validation at the OAuth callback.** The callback trusted the + `state` parameter alone. `state` proves a response belongs to a flow we + started; it does **not** prove which authorization server issued the code, so + a malicious or compromised MCP server could steer the callback and have a code + minted by one AS redeemed at another. `iss` is now validated against the + issuer bound to the flow **before** the code is exchanged — a mismatch, or an + absent `iss` from an AS that advertised + `authorization_response_iss_parameter_supported`, is rejected and persists + nothing. Whether the AS advertised `iss` is captured at authorize time + (`mcp_oauth_flows.iss_required`), never re-discovered at the callback, for the + same reason migration 0016 pinned the token endpoint. +- **Confused deputy removed.** Both the operator router and the runtime + `McpManager` resolved the OAuth user key as `… ?? 'operator'`. A Teams or + Telegram turn whose user had no mapped identity therefore reached the + customer's MCP server holding the **operator's** token. Resolution is now + explicit per server via the new `mcp_servers.delegation` column: `per_user` + fails closed through the existing `onAuthFailure` path when no identity + resolves, and `service` is the explicit opt-in to one shared identity. The + fallback literal is gone from every call site. +- **Refresh race.** `getValidAccessToken` allowed N concurrent refreshes per + (server, user). Against an AS with rotating refresh tokens the losers get + `invalid_grant` and the last writer can persist an already-retired token, + silently disconnecting the user. Concurrent callers now share one in-flight + refresh, verified by a test that asserts exactly one token-endpoint **HTTP + request** under 8 concurrent callers. +- `mcp_oauth_tokens.issuer` records which AS minted a token, so a rotated issuer + invalidates it instead of replaying it against a different server. +- `mcp_call_log.acting_identity` records **whose** authority each call used + (`caller_agent` is the orchestrator slug, not the identity); an unattributable + call is recorded as `unresolved` rather than left blank. +- OAuth failure logging now goes through a redactor + (`middleware/src/services/secretRedaction.ts`) — tokens, `code`, and + `code_verifier` can no longer reach a log line, including values echoed back + by a provider that we never minted. + +> ⚠️ **Operator-visible behaviour change.** A fail-closed `per_user` default for +> every row would break installed deployments whose channel users reach MCP +> servers today *because of* the `'operator'` fallback. The migration is +> therefore deliberately asymmetric: every **existing** `mcp_servers` row that +> already holds a stored operator token is set to `delegation = 'service'`, +> preserving today's behaviour, and only **newly created** servers get the safe +> `per_user` default. Review each grandfathered server in the MCP Control Center +> and switch the ones that should be per-user — while a server stays on +> `service`, anyone who can reach an orchestrator it is granted to acts with the +> operator's authority at that server. +### Deprecated — legacy HTTP+SSE MCP transport (#541) + +- MCP 2026-07-28 reclassifies the legacy HTTP+SSE transport as **Deprecated**, + with a removal window of at least 12 months. omadia now discourages `sse` for + **new** registrations while keeping every existing SSE server fully working — + this is a discouragement, not a removal. No protocol work: `SSEClientTransport` + stays wired, the `agent_mcp_servers.transport` CHECK constraint still accepts + `'sse'`, and no migration ships with this change. Streamable HTTP (`http`) is + the migration target. +- `@omadia/orchestrator` exports `DEPRECATED_MCP_TRANSPORTS` and + `isDeprecatedMcpTransport()` as the single source of truth. The operator API's + MCP server node gained an additive `transportDeprecated: boolean` derived from + it; `McpTransport`/`McpTransportKind` keep `'sse'` in every union, so the + published plugin contract is unchanged. +- **MCP Control Center:** `sse` is no longer offered in the transport picker + unless "Show deprecated transports" is ticked (`http` remains the default), + and existing `sse` servers carry a *Deprecated* badge pointing at Streamable + HTTP. Nothing is hard-blocked — an operator can still deliberately register a + legacy SSE server while the removal window is open. +- **Marketplace imports** are covered too, not just the UI: when a catalog entry + advertises both a Streamable-HTTP and an HTTP+SSE remote, the importer now + picks the `http` one. An `sse`-only entry still imports, flagged via + `McpCatalogEntry.transportDeprecated`. The untrusted-remote guard (https only, + no internal/metadata hosts) applies to every candidate as before. +### Added — MCP structured-content sidecar and `outputSchema` capture (#547, W1-3) + +- Discovery now keeps a tool's declared `outputSchema`. `McpToolDescriptor` + and `McpDiscoveredTool` gained an optional `outputSchema` field, and + `McpManager.listTools()` copies it from `tools/list` (object-valued only; + anything else is dropped rather than propagated). It is persisted with the + rest of the descriptor in the existing `mcp_servers.discovered_tools` + `jsonb` column, so it survives a restart without re-discovery — **no + migration required**. `subAgentToolHydration` rehydrates it on the way back + out. +- `structuredContent` returned by an MCP tool is no longer discarded. A new + `extractStructured(res)` reads it, and `McpManager` hands it to an optional + `McpManagerOptions.structuredSink` as `{ kind: 'structured_output', + serverId, toolName, turnId, structured, outputSchema? }`, keyed so a + consumer can correlate it with the turn that produced it. Error results and + absent/null payloads emit nothing. +- This is deliberately an **out-of-band** channel, not a widened return type. + `McpManager.callTool()` still returns `Promise` and + `NativeToolHandler` is untouched, which keeps the published plugin contract + stable and — more importantly — keeps every MCP result on the + `typeof result === 'string'` path that gates Privacy Shield masking in the + orchestrator. A non-string result would silently bypass the shield. +- Operator surface: the MCP Control Center's tool list shows a read-only + "returns structured output" badge for any tool that declares an output + schema. +- No canvas/synthesis behaviour is attached yet — this change is plumbing + only. The sink's payload union is a discriminated `kind` so the MRTR work + (#544) can add `input_required` without another refactor. +### Changed — long-running tools stop blocking chat turns (#543) + +- New generic **long-running task seam** in `@omadia/orchestrator` + (`TaskDescriptor` / `TaskStore`, `defineLongRunningTool`). Mark a tool + `longRunning` and it gets a non-blocking `_start` / `_status` / + `_list` triple plus a streaming status card: `_start` returns a handle in + milliseconds, the work runs detached, and the model collects the result on a + later poll. Generalized from the `dev_job_*` tools, which hand-rolled exactly + this shape. +- **A chat turn is never parked.** There is no park/resume for a chat turn — + `chat.ts` streams SSE with a heartbeat and ends when the model loop ends — so + holding the stream open for minutes only buys proxy idle timeouts, Teams + activity expiry, and reaped connections. The model says "started, I'll report + back" instead; that is the intended UX. +- **`dev_job` is the seam's first implementor, with no behaviour change.** A new + adapter projects `DevJobStore` onto the seam (ten-value `DevJobStatus` down to + `working | input_required | completed | failed`, `dev_job_events` onto the event + tail, `claimNextQueued` onto the claim, `finalizeDevJob` onto the terminal + write so the brand-gated choke point is preserved). `dev_job_start` still + returns `{"status":"job_started",…}`; nothing in `devJobStore.ts` or + `devJobOrchestratorTool.ts` changed and no migration was added. +- **Deferred sub-agent dispatch** is the second consumer. A slow sub-agent + delegated from a chat turn blocks that turn for as long as its `LocalSubAgent` + loop runs; opt one in via `LONG_RUNNING_SUBAGENT_TOOLS` (comma-separated + `ask_` names) and it also gets the non-blocking triple. The blocking + `ask_` tool stays registered either way, so a sub-agent that answers in + seconds keeps answering inline. Empty by default — no existing behaviour moves. +- **Orphan handling**: a periodic reaper fails live tasks whose worker went + silent (including tasks no worker ever claimed) and purges terminal tasks past + a retain window, so an unpolled task cannot leak a `working` row forever. + Windows: `LONG_RUNNING_TASK_STALE_MS` (default 15 min), + `LONG_RUNNING_TASK_RETAIN_MS` (default 1 h). +- **Deferred-result privacy**: a task's result reaches the model only as the + return value of `_status` — an ordinary tool call inside a live turn — so + the Privacy Shield interning that `dispatchTool` performs still applies, at poll + time instead of completion time. Status cards deliberately carry no result and + no input (they bypass `dispatchTool`), which is enforced by test. Known v1 + limitation: privacy **bypass attribution** for work done inside the detached + runner cannot be recorded against the originating turn, since that turn has + already ended. No data leaks; the audit line is what is missing. +- Not the MCP Tasks extension: internal `LocalSubAgent` dispatches never cross an + MCP boundary, and the redesigned extension (SEP-2663) is unshipped even in SDK + v2 (`tasks/update` does not exist). The status vocabulary above was chosen to + match MCP Tasks so a later protocol projection is mechanical. + ### Added — API keys as a first-class authentication method, with per-key scopes (#439) - New workspace package `@omadia/api-key-auth` @@ -399,6 +909,7 @@ entry. See `CONTRIBUTING.md` § Releases & changelog. the hand-written `0005_turn_embeddings_768.sql`-style migration as the only route. `'true'` (default) permits it *when an operator confirms it*; it can no longer let a restart wipe a corpus. + ### Added — plugin-contributed navigation (#470, phase 1 of the Dev Platform extraction) - New plugin capability `ctx.uiRoutes.registerNav({ navId, href, cluster?, diff --git a/docs/adr/0006-mcp-client-id-metadata-documents.md b/docs/adr/0006-mcp-client-id-metadata-documents.md new file mode 100644 index 00000000..94964e92 --- /dev/null +++ b/docs/adr/0006-mcp-client-id-metadata-documents.md @@ -0,0 +1,172 @@ +# 0006 — Client ID Metadata Documents coexist permanently with manual OAuth clients + +## Status + +Accepted + +- **Date:** 2026-07-30 +- **Deciders:** omadia maintainers +- **Supersedes:** — + +## Context and Problem Statement + +omadia has shipped a provider-agnostic MCP OAuth 2.1 + PKCE stack since epic +#459 W9. To obtain the OAuth client it presents to an authorization server it +had two paths: RFC 7591 Dynamic Client Registration (DCR), and a client an +operator registers once by hand. The MCP authorization spec now deprecates DCR +in favour of **Client ID Metadata Documents (CIMD)**, where the `client_id` is +an https URL the authorization server *dereferences* to read `redirect_uris` +and `client_name`. + +Issue #546 framed CIMD as "the enterprise answer" and implied the manual client +was a stopgap. Should omadia adopt CIMD as the new default and sunset the manual +path? + +## Decision Drivers + +- **The two real IdPs in enterprise deployments — Microsoft Entra ID and Okta — + do not support CIMD.** They use pre-registered app registrations. For them the + manual client is not a workaround; it is the protocol-correct path. +- **CIMD inverts the network direction.** Every other mode only requires omadia + to reach *out*: a redirect the operator's browser follows, an outbound token + POST. CIMD requires the identity provider to reach *in* and GET a URL on + omadia's own host. That is a strictly stronger requirement. +- Many omadia installs are on-premises or behind a corporate firewall and have + **no inbound HTTPS route at all**. A design that needs one cannot be the only + path. +- DCR's deprecation is on a 12-month clock, and brokers that only offer DCR must + keep working throughout. +- Single tenancy: **no table in any migration carries a tenant column.** One + omadia install serves one organization, so a CIMD `client_id` identifies the + whole install. + +## Considered Options + +- **A — CIMD as one link in an explicit chain; manual stays permanent.** +- **B — CIMD as the default, manual deprecated behind a compatibility flag.** +- **C — byte5 hosts a metadata relay so every install gets a working + `client_id` regardless of inbound reachability.** + +## Decision Outcome + +Chosen option: **A**. + +The client-acquisition chain is explicit and ordered: + +``` +stored → cimd → dcr (deprecated, warns) → manual → McpOAuthNeedsClientError +``` + +CIMD is attempted **only** when both sides agree it can work: the authorization +server advertises `client_id_metadata_document_supported`, a metadata URL is +configured, and the document is verifiably reachable. Any of those failing makes +the chain fall through — never fail. + +**Option C was rejected outright.** A byte5-hosted relay would mean every +customer's `client_id` is a URL on a byte5 domain, so every customer's OAuth +client would *identify byte5* to that customer's identity provider. That is +wrong on identity grounds before it is wrong on availability grounds, and it +inserts byte5 into a customer's authorization path. It is not offered by +default, and it is not planned. + +**Option B was rejected** because it inverts which path is load-bearing: it +would deprecate the only mode Entra ID and Okta can use. + +### Consequences + +- 🟢 **Good:** CIMD removes the app-registration step at MCP-native brokers + (Smithery-class) without changing anything for IdP-backed servers. +- 🟢 **Good:** A firewalled install degrades cleanly. The metadata endpoint + answers **501 with an actionable message**, not 500, and the manual path keeps + working untouched. +- 🟢 **Good:** DCR keeps working and merely logs a deprecation notice, so no + broker breaks on our timeline. +- 🔴 **Bad:** Three acquisition modes is more surface than two. Mitigated by + making the chain a single ordered function and surfacing the resolved mode in + the UI, so an operator can always see which one applies and why. +- 🔴 **Bad:** Reachability cannot be *proven* from inside the process — only the + IdP's own network can answer it. We check the conditions that make the answer + definitively "no" (see below) and treat a "yes" as a strong necessary + condition, not a guarantee. +- ⚪ **Neutral:** `mcp_oauth_clients.registered_via` gains `'cimd'` and a + `client_metadata_url` column (migration `0032`). + +## Deployment note + +**CIMD requires inbound HTTPS reachability.** Set `FLOW_PUBLIC_BASE_URL` to an +https origin this deployment is reachable at **from the internet**. The identity +provider fetches `GET {FLOW_PUBLIC_BASE_URL}/.well-known/omadia-mcp-client` +itself; an origin that only resolves inside your network will not do. + +- The metadata URL is derived from `FLOW_PUBLIC_BASE_URL` **alone** — + deliberately not the `?? PUBLIC_BASE_URL` fallback the redirect URI uses, + because `PUBLIC_BASE_URL` defaults to `http://localhost:3979`, exactly the + shape that is not inbound reachable. Requiring an explicit declaration means an + unconfigured install lands in the clean degraded state rather than publishing a + `client_id` no provider can fetch. +- It is derived from **config, never from the inbound `Host` header**, so it is + stable across restarts and proxy hops. The URL *is* the `client_id` and stored + `mcp_oauth_clients` rows reference it. +- The served `redirect_uris` must equal `McpOAuthService.redirectUri` exactly. If + they diverge, the authorization server matches the authorize request's + `redirect_uri` against the document, finds no match, and every code exchange + fails — at the provider, far from the cause. Both are wired from one variable + in `index.ts`, and `middleware/test/mcpOAuth.test.ts` asserts the equality. +- **If inbound access is not possible, nothing is broken.** Register a one-time + OAuth client per issuer in the MCP Control Center. That path is fully + supported, permanently, and is the correct path for Entra ID and Okta. +- **A byte5-hosted metadata relay is not offered by default** — see Option C + above. +- **Single tenancy is the reality.** No migration defines a tenant column. One + install serves one organization, `mcp_oauth_clients` is keyed by issuer alone, + and the CIMD `client_id` identifies the install as a whole. Do not read + multi-tenancy into this schema. + +### Reachability check + +The probe (`services/mcpCimd.ts`) reuses `assertPublicHttpsUrl` from +`services/ssrfGuard.ts` — the same guard the RFC 9728 / RFC 8414 discovery chain +uses, deliberately not a second validator. It rejects: + +1. no configured public base origin; +2. plain http, and RFC 1918 / loopback / link-local / CGNAT literals, + `.internal` / `.local` names, and hostnames that DNS-resolve into those + ranges; +3. a URL that does not serve *our* document — the fetched document's `client_id` + must equal the URL fetched, so a catch-all proxy route answering `200` with + something else is caught. + +The verdict is cached (5 min, single-flight) because it runs on every MCP Control +Center status poll. + +## Security properties preserved + +- A CIMD client is **public by construction**: the document is world-readable, so + there is no client secret and PKCE alone protects the exchange. + `token_endpoint_auth_method` is `"none"` — an accurate claim, not a shortcut. +- The metadata document carries **no secret**: only the redirect URI and a + display name, both of which the IdP already sees during the authorize + round-trip. +- Tokens stay in the vault namespace; `mcp_oauth_tokens` holds refs only. +- No token, `code`, or `code_verifier` reaches a log line — OAuth error text goes + through `services/secretRedaction.ts`. +- `mcp_oauth_flows` TTL is enforced in two places (verified, not assumed): an + opportunistic prune of rows older than 15 minutes on every flow create, and an + age-bounded `DELETE … RETURNING` on consume, so a leaked stale `state` cannot + be redeemed later even if no prune has run. +- The flow-bound endpoint pinning and the RFC 9207 `iss` validation added in W0-1 + are untouched. + +## More Information + +- Issue #546 (CIMD half, W2-4). The issue body's premise that the registry + "supports only static headers with `secretRef`" is **incorrect** — the full + OAuth 2.1 + PKCE stack shipped in epic #459 W9. +- Implementation: `middleware/src/services/mcpCimd.ts`, + `middleware/src/services/mcpOAuthService.ts` (`ensureClient`), + `middleware/src/routes/mcpClientMetadata.ts`, + `middleware/src/auth/publicPaths.ts`, + `middleware/migrations/0032_mcp_oauth_cimd.sql`. +- Tests: `middleware/test/mcpOAuth.test.ts`, + `middleware/test/publicPaths.test.ts`, + `middleware/test/mcpOAuthCimdMigration.pg.test.ts`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 79e0e0b6..fbc3785d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,7 @@ decision, write a new ADR and mark the old one **Superseded by …**. | 0003 | [Capability-based, multi-provider middleware](0003-capability-based-multi-provider-middleware.md) | Accepted | 2026-06-03 | | 0004 | [Knowledge graph as the agent memory substrate](0004-knowledge-graph-as-memory-substrate.md) | Accepted | 2026-06-03 | | 0005 | [Two-phase confirmation for write-capable connectors](0005-two-phase-confirmation-for-writes.md) | Accepted | 2026-06-03 | +| 0006 | [Client ID Metadata Documents coexist permanently with manual OAuth clients](0006-mcp-client-id-metadata-documents.md) | Accepted | 2026-07-30 | > These first records are written *retroactively* — they document decisions that > were already implemented and proven in the product. New decisions should be diff --git a/middleware/migrations/0031_mcp_oauth_iss_delegation.sql b/middleware/migrations/0031_mcp_oauth_iss_delegation.sql new file mode 100644 index 00000000..f2780153 --- /dev/null +++ b/middleware/migrations/0031_mcp_oauth_iss_delegation.sql @@ -0,0 +1,145 @@ +-- ── MCP OAuth: RFC 9207 `iss` binding + explicit delegation mode (W0-1) ───── +-- Three live defects in the MCP OAuth path are closed here: +-- +-- D1 The callback trusted `state` alone and never validated the RFC 9207 +-- `iss` authorization-response parameter against the issuer recorded for +-- the flow. `mcp_oauth_flows.issuer` already exists; what was missing is +-- knowing whether the authorization server ADVERTISED iss support, so an +-- absent `iss` from an AS that promised one can be rejected. That flag is +-- captured at authorize time (never re-discovered at callback — same +-- reasoning as migration 0016). +-- +-- D2 `oauthUserKey()` silently fell back to the shared literal 'operator', +-- so a channel turn (Teams/Telegram) with no mapped identity inherited +-- the operator's authority at the customer's MCP server — a confused +-- deputy. `mcp_servers.delegation` makes the choice explicit per server: +-- per_user → the acting identity must resolve, or the call fails closed +-- service → one shared identity is the deliberate, opted-in design +-- +-- D3 Concurrent refreshes for the same (server, user) raced each other. Not +-- a schema concern, but `mcp_oauth_tokens.issuer` lands here so a stored +-- token can be invalidated when its issuer rotates. +-- +-- ⚠️ OPERATOR-VISIBLE BEHAVIOUR CHANGE — read before deploying. +-- A fail-closed `per_user` default for EVERY row would break installed systems +-- whose channel users reach MCP servers today precisely BECAUSE of the +-- 'operator' fallback. So this migration is deliberately asymmetric: +-- • when `mcp_servers.delegation` is introduced, pre-existing rows that +-- already hold an operator token are grandfathered to today's shared +-- behaviour (`delegation = 'service'`), +-- • operators who later opt an existing server into `per_user` stay there on +-- every re-apply, because that grandfathering runs only on the first +-- application that adds the column, and +-- • only NEWLY created servers get the safe `per_user` default. +-- That one-time asymmetry is intentional. A new server that later acquires an +-- operator token is still `per_user` unless an operator deliberately chooses +-- shared delegation in the MCP Control Center (or UPDATEs the column directly). + +-- ── D2: explicit delegation mode per MCP server ───────────────────────────── +-- The column introduction and the compatibility backfill are one decision and +-- must happen exactly once. Re-applying 0031 after an operator has opted an +-- existing server into `per_user` must preserve that operator decision: +-- changing `delegation` does NOT delete the stored operator token row, so a +-- standing backfill would silently flip the server back to `service` on the +-- next apply and reopen D2 for unmapped channel users. +-- +-- Backward compatibility on that first apply only: every EXISTING server that +-- already holds an OPERATOR token keeps the shared identity it is working with +-- today. Guarded by `to_regclass` so the migration is safe on a database where +-- `mcp_oauth_tokens` has not been created yet. The argument is UNQUALIFIED on +-- purpose: it must resolve through `search_path` like every other reference in +-- this file. A hardcoded `public.` would probe the wrong schema wherever the +-- domain is applied outside `public` — the guard would then answer about a +-- table this statement does not touch, and the backfill would be skipped (or +-- run) for a reason unrelated to the data in front of it. +-- +-- The predicate is `user_key = 'operator'`, NOT "has any token row". This +-- backfill exists solely to preserve the behaviour the 'operator' fallback was +-- producing (see D2 above), and that fallback only ever applied where an +-- operator token existed to borrow. A server holding only per-user tokens — +-- `user_key = 'alice@corp.com'` and nothing else — was never using a shared +-- identity, so flipping it to `service` would be a silent identity change no +-- operator decided on: `resolveMcpUserKey` would hand every caller the shared +-- `operator` key, and once anyone completed a re-auth the minted operator token +-- would be shared by every caller, including unmapped channel users. The +-- narrow predicate leaves such a server on the safe `per_user` default, which +-- is the choice its stored tokens already imply. +-- +-- The literal must stay in sync with `SERVICE_USER_KEY` in +-- `src/services/mcpDelegation.ts` (a migration cannot import it). +DO $$ +DECLARE + delegation_exists BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = 'mcp_servers'::regclass + AND attname = 'delegation' + AND NOT attisdropped + ) + INTO delegation_exists; + + IF NOT delegation_exists THEN + -- Plain ADD COLUMN, not IF NOT EXISTS: the catalog gate above already + -- proved absence, and masking a broken gate here would turn a logic error + -- into silent drift instead of failing loudly at the statement that broke. + ALTER TABLE mcp_servers + ADD COLUMN delegation TEXT NOT NULL DEFAULT 'per_user'; + + IF to_regclass('mcp_oauth_tokens') IS NOT NULL THEN + UPDATE mcp_servers s + SET delegation = 'service' + WHERE EXISTS ( + SELECT 1 FROM mcp_oauth_tokens t + WHERE t.server_id = s.id + AND t.user_key = 'operator' + ); + END IF; + END IF; +END $$; + +-- `conname` is unique per (connamespace, conrelid), NOT cluster-wide, so an +-- unanchored lookup reports "exists" for a same-named constraint sitting in any +-- other schema and the ALTER below is silently skipped. Anchoring on +-- `conrelid = 'mcp_servers'::regclass` resolves the relation through +-- `search_path`, matching every other unqualified reference in this file. The +-- cast cannot raise here for any new reason: this migration still requires +-- `mcp_servers` to resolve, the gate block above already resolved the same +-- regclass while checking whether `delegation` exists, and the old `ALTER TABLE +-- ... ADD COLUMN IF NOT EXISTS` would likewise have failed on a missing table +-- because `IF NOT EXISTS` guards the column name, not the relation. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'mcp_servers_delegation_chk' + AND conrelid = 'mcp_servers'::regclass + ) THEN + ALTER TABLE mcp_servers + ADD CONSTRAINT mcp_servers_delegation_chk + CHECK (delegation IN ('per_user', 'service')); + END IF; +END $$; + +-- ── D1: remember whether the AS advertised RFC 9207 at authorize time ─────── +-- NULL on pre-0031 in-flight flows → treated as "not advertised", so a flow +-- started before this migration is not retroactively rejected for a missing +-- `iss`. A mismatched `iss` is rejected regardless of this flag. +ALTER TABLE mcp_oauth_flows + ADD COLUMN IF NOT EXISTS iss_required BOOLEAN NOT NULL DEFAULT false; + +-- ── D3 companion: bind a stored token to the issuer that minted it ────────── +-- Lets a token be invalidated when the server's issuer rotates instead of +-- being replayed against a different authorization server. +ALTER TABLE mcp_oauth_tokens + ADD COLUMN IF NOT EXISTS issuer TEXT; + +-- ── Audit: record the acting identity on every MCP call ───────────────────── +-- `caller_agent` is the orchestrator/sub-agent slug, not WHO the call acted +-- as. Without this an operator cannot answer "whose credentials touched that +-- server?" — the exact question the confused-deputy bug raises. +ALTER TABLE mcp_call_log + ADD COLUMN IF NOT EXISTS acting_identity TEXT; + +-- rollback: ALTER TABLE mcp_call_log DROP COLUMN acting_identity; ALTER TABLE mcp_oauth_tokens DROP COLUMN issuer; ALTER TABLE mcp_oauth_flows DROP COLUMN iss_required; ALTER TABLE mcp_servers DROP CONSTRAINT mcp_servers_delegation_chk, DROP COLUMN delegation; diff --git a/middleware/migrations/0032_mcp_oauth_cimd.sql b/middleware/migrations/0032_mcp_oauth_cimd.sql new file mode 100644 index 00000000..ad307a6f --- /dev/null +++ b/middleware/migrations/0032_mcp_oauth_cimd.sql @@ -0,0 +1,52 @@ +-- ── W2-4: Client ID Metadata Documents as a THIRD client-acquisition mode ─── +-- (issue #546, CIMD half) +-- +-- Context that the issue body gets wrong: omadia does NOT "only support static +-- headers with secretRef". A complete provider-agnostic OAuth 2.1 + PKCE stack +-- shipped in epic #459 W9 (migration 0015, services/mcpOAuth*.ts). This +-- migration is a delta on that stack, not a new subsystem. +-- +-- CIMD lets the client identify itself by an https URL that the authorization +-- server FETCHES, instead of pre-registering a client_id. It replaces Dynamic +-- Client Registration against MCP-native brokers (Smithery-class) — and ONLY +-- those. It is not the enterprise path: +-- +-- • Entra ID and Okta do not support CIMD. They use pre-registered app +-- registrations, for which the correct path is the EXISTING manual client +-- (`setManualClient`, registered_via = 'manual'). +-- • CIMD additionally requires the IdP to make an INBOUND https request to +-- omadia. That is strictly stronger than the outbound-redirect-only +-- requirement the manual path has, and impossible behind a corporate +-- firewall or on an air-gapped install. +-- +-- Decision recorded here so a later reader does not "clean up" the manual path: +-- 'cimd' and 'manual' COEXIST PERMANENTLY. There is no sunset for 'manual', and +-- 'dcr' is deprecated by the MCP spec on a 12-month clock but still works. +-- +-- Single tenancy: no table in any migration carries a tenant column. One +-- omadia install serves one organization, so `mcp_oauth_clients` is keyed by +-- issuer alone and a CIMD client_id identifies THIS install globally. + +-- ── Widen registered_via to admit the third mode ───────────────────────────── +-- 0015 created the constraint inline, so Postgres named it +-- `mcp_oauth_clients_registered_via_check`. Drop whichever name is present and +-- re-add an explicitly named one so a future migration has a stable handle. +ALTER TABLE mcp_oauth_clients + DROP CONSTRAINT IF EXISTS mcp_oauth_clients_registered_via_check; +ALTER TABLE mcp_oauth_clients + DROP CONSTRAINT IF EXISTS mcp_oauth_clients_registered_via_chk; +ALTER TABLE mcp_oauth_clients + ADD CONSTRAINT mcp_oauth_clients_registered_via_chk + CHECK (registered_via IN ('dcr', 'manual', 'cimd')); + +-- ── The metadata document this client_id resolves to ──────────────────────── +-- For registered_via = 'cimd' this is the self-referential https URL that IS +-- the client_id (RFC-style CIMD: the client_id is the document's own URL, and +-- the AS dereferences it to read redirect_uris / client_name). Recorded +-- separately from client_id so an operator can see at a glance which document +-- a stored client was acquired from, and so a base-URL change is detectable. +-- NULL for 'dcr' and 'manual' rows. +ALTER TABLE mcp_oauth_clients + ADD COLUMN IF NOT EXISTS client_metadata_url TEXT; + +-- rollback: ALTER TABLE mcp_oauth_clients DROP COLUMN client_metadata_url, DROP CONSTRAINT mcp_oauth_clients_registered_via_chk, ADD CONSTRAINT mcp_oauth_clients_registered_via_check CHECK (registered_via IN ('dcr', 'manual')); diff --git a/middleware/migrations/0033_public_mcp_keys.sql b/middleware/migrations/0033_public_mcp_keys.sql new file mode 100644 index 00000000..c25cebc6 --- /dev/null +++ b/middleware/migrations/0033_public_mcp_keys.sql @@ -0,0 +1,123 @@ +-- ── W2-3: the public stateless MCP endpoint's per-key authorization ───────── +-- (issue #542) +-- +-- Correcting the issue body before anything else, because the schema below is +-- shaped by the correction: #542 claims "the delta is transport exposure + +-- auth, not new tool plumbing". That is false. `ToolDispatchService` (the +-- dispatcher the loopback MCP server already uses) carries an explicit SEAM +-- comment recording that kernel-tool branches, scoped-memory shadowing, +-- privacy interning and trace capture are deliberately NOT replicated versus +-- `Orchestrator.dispatchToolInner`, and dispatch carries no tenant, user or +-- principal at all. Closing that seam is a SIBLING unit; this migration +-- provides the authorization data the endpoint needs either way. +-- +-- Two things live here: +-- 1. `public_mcp_key_bindings` — the per-key allowlist and agent binding. +-- 2. a widened `mcp_call_log.caller_kind`, so a public MCP call is auditable +-- as what it actually is rather than mislabelled as one of the five +-- in-process caller kinds. + +-- ── 1. Per-key tool allowlist + agent binding ─────────────────────────────── +-- WHY A TABLE RATHER THAN MORE SCOPES ON THE KEY RECORD +-- +-- Scopes (`@omadia/api-key-auth`) answer "what class of thing may this key +-- do": list, invoke, write-this-tool. They are vault-resident, per key, and +-- deliberately free-form so plugins can mint their own. What they cannot +-- answer is "WHICH tools, on WHICH agent" — a set that an operator edits, that +-- wants to be inspectable in a query, and that must default to nothing. +-- +-- Both halves are required for a call to succeed, and they are checked +-- independently: +-- - the SCOPE says the key holds the capability; +-- - this ROW says the key reaches that specific tool on that specific agent. +-- Neither is sufficient. A key whose scopes say `mcp:write:create_lead` but +-- whose row does not list `create_lead` reaches nothing, and vice versa. That +-- redundancy is deliberate: the two live in different stores (vault vs. DB) +-- with different write paths, so a mistake in one is not a mistake in both. +-- +-- ALLOWLIST, NEVER DENYLIST. A key with no row here reaches ZERO tools — it +-- authenticates and is authorized for nothing. That is what makes +-- integration-backed and write-capable tools (Odoo, M365, Confluence) excluded +-- by DEFAULT: they are excluded because nothing is included until an operator +-- names it. A denylist would have made every future tool reachable the moment +-- it was registered, which is a privilege escalation delivered by a deploy. +-- +-- KEY → EXACTLY ONE AGENT. `agent_id` is scalar, not an array, and is the +-- primary-key-adjacent fact of this table. omadia had no seam for "which +-- agent's tools does this caller see" — the native tool registry is a process +-- -wide singleton with unique names, and per-agent scoping existed only for +-- DomainTools. This column IS that seam. A key bound to agent A cannot reach +-- agent B's tools even when both agents' tools sit in the same registry, +-- because the endpoint resolves the dispatcher from THIS column and filters to +-- THIS row's allowlist. +CREATE TABLE IF NOT EXISTS public_mcp_key_bindings ( + -- `ApiKeyRecord.id` from `@omadia/api-key-auth`. Not a foreign key: those + -- records live in the secret vault, not in Postgres, so the database cannot + -- enforce the reference. The endpoint verifies the key FIRST (constant-time + -- hash compare against the vault) and only then reads this row, so an + -- orphaned row grants nothing — it is unreachable without a live key whose + -- id matches. + key_id TEXT PRIMARY KEY, + + -- The ONE agent (orchestrator slug) whose tools this key reaches. + agent_id TEXT NOT NULL CHECK (length(agent_id) > 0), + + -- Exact tool names, no patterns. A pattern would reintroduce the "I thought + -- `odoo_*` didn't cover `odoo_delete_invoice`" mistake that per-tool scopes + -- exist to prevent, and would silently widen on every newly-registered tool. + read_tools TEXT[] NOT NULL DEFAULT '{}', + + -- Write-capable subset, named separately rather than inferred. omadia has no + -- per-tool "is this a write" metadata today: `DispatchableToolSpec` carries + -- name/description/input_schema and nothing about effects. Inferring from the + -- name ("does it start with create_/update_/delete_") would be a guess that + -- fails open on the first tool named `submit_expense`. So the operator + -- declares it, and a tool listed here additionally requires the key to hold + -- `mcp:write:` AND spends the tighter write rate-limit budget. + write_tools TEXT[] NOT NULL DEFAULT '{}', + + -- Separate, tighter budget than the key's general `rateLimitPerMinute`. + -- Reads are cheap and idempotent; a write is neither. Sharing one budget + -- would let a read-heavy integration's unused headroom fund a write burst. + write_rate_limit_per_minute INTEGER NOT NULL DEFAULT 5 + CHECK (write_rate_limit_per_minute BETWEEN 0 AND 600), + + -- 0 disables the binding without deleting it (and without revoking the key, + -- which may still be used for chat). Distinct from "no row": an operator can + -- see that this key WAS configured and is currently parked. + enabled BOOLEAN NOT NULL DEFAULT true, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Answers "which keys can reach this agent" without a sequential scan once an +-- install has more than a handful of integrations. +CREATE INDEX IF NOT EXISTS public_mcp_key_bindings_agent_idx + ON public_mcp_key_bindings (agent_id); + +-- ── 2. Audit a public MCP call as what it is ──────────────────────────────── +-- 0009 constrained `caller_kind` to the five IN-PROCESS caller kinds (agent, +-- subagent, skill, plugin, unattributed). A public MCP call is none of them: +-- there is no orchestrator turn, no sub-agent, no plugin — there is an API key +-- held by a third party. Squeezing it into `plugin` or `unattributed` would +-- make the one question this row exists to answer ("was this an internal turn +-- or the internet?") unanswerable from the data. +-- +-- `acting_identity` (added by 0031 for the confused-deputy fix) carries the +-- key: `apikey:`, or the literal `unresolved` when the identity could +-- not be established — the SAME vocabulary 0031 established, reused rather than +-- reinvented, so one operator query covers both sources. +-- +-- 0009 created the constraint inline, so Postgres named it +-- `mcp_call_log_caller_kind_check`. Drop whichever name is present and re-add +-- an explicitly named one, so a future migration has a stable handle. +ALTER TABLE mcp_call_log + DROP CONSTRAINT IF EXISTS mcp_call_log_caller_kind_check; +ALTER TABLE mcp_call_log + DROP CONSTRAINT IF EXISTS mcp_call_log_caller_kind_chk; +ALTER TABLE mcp_call_log + ADD CONSTRAINT mcp_call_log_caller_kind_chk + CHECK (caller_kind IN ('agent', 'subagent', 'skill', 'plugin', 'unattributed', 'api_key')); + +-- rollback: DELETE FROM mcp_call_log WHERE caller_kind = 'api_key'; ALTER TABLE mcp_call_log DROP CONSTRAINT mcp_call_log_caller_kind_chk, ADD CONSTRAINT mcp_call_log_caller_kind_check CHECK (caller_kind IN ('agent', 'subagent', 'skill', 'plugin', 'unattributed')); DROP TABLE public_mcp_key_bindings; diff --git a/middleware/package.json b/middleware/package.json index 9fdca3bb..7e93f6b5 100644 --- a/middleware/package.json +++ b/middleware/package.json @@ -38,7 +38,7 @@ "smoke:package-roundtrip": "tsx scripts/smoke-package-roundtrip.ts", "setup:tigris-lifecycle": "tsx scripts/setup-tigris-lifecycle.ts", "pretest": "node scripts/check-node-version.mjs", - "test": "node --import tsx --test --test-reporter=spec 'test/**/*.test.ts'" + "test": "node --import tsx --test --test-timeout=120000 --test-reporter=spec 'test/**/*.test.ts'" }, "engines": { "node": ">=22.13.0 <23" diff --git a/middleware/packages/harness-api-key-auth/src/apiKeyScopes.ts b/middleware/packages/harness-api-key-auth/src/apiKeyScopes.ts index 4ce83105..7a0b8195 100644 --- a/middleware/packages/harness-api-key-auth/src/apiKeyScopes.ts +++ b/middleware/packages/harness-api-key-auth/src/apiKeyScopes.ts @@ -25,6 +25,48 @@ export const WILDCARD_SCOPE = '*'; /** The capability the public chat ingress requires (`@omadia/channel-api`). */ export const CHAT_WRITE_SCOPE = 'chat:write'; +/** W2-3 (issue #542) — enumerate the tools the public MCP endpoint exposes to + * this key. Seeing a tool name is itself a disclosure, so listing is its own + * capability rather than a free side effect of authenticating. */ +export const MCP_LIST_SCOPE = 'mcp:list'; + +/** W2-3 — call a READ tool over the public MCP endpoint. Deliberately NOT + * sufficient for a write: see `MCP_WRITE_SCOPE_PREFIX`. */ +export const MCP_INVOKE_SCOPE = 'mcp:invoke'; + +/** + * W2-3 — prefix of the per-tool write capability, `mcp:write:`. + * + * Marcel's decision to expose write tools (not just reads) over a PUBLIC + * endpoint is what makes this granularity a requirement rather than a nicety. + * Three properties hold, and each exists because the coarser alternative is a + * real escalation: + * + * - It is PER TOOL. `mcp:invoke` authorizes reads as a class; there is no + * equivalent class-wide write scope, because "this integration may write" + * is never the sentence an operator means — they mean "this integration may + * call `create_lead`", and nothing else. + * - It is NOT reachable via `WILDCARD_SCOPE`. `*` is a convenience for an + * operator's own tooling; silently including "delete every Odoo invoice via + * an internet-facing endpoint" in that convenience is not a trade anyone + * consciously makes. `hasScope` enforces this for every caller — see there. + * - It is THREE segments, so it cannot collide with, or be satisfied by, any + * two-segment scope an operator or plugin already minted. + */ +export const MCP_WRITE_SCOPE_PREFIX = 'mcp:write:'; + +/** Builds the write capability for one tool. Use this rather than + * concatenating, so the prefix has exactly one definition. */ +export function mcpWriteScope(toolName: string): ApiKeyScope { + return `${MCP_WRITE_SCOPE_PREFIX}${toolName}`; +} + +/** True for a `mcp:write:` scope. Drives the wildcard exclusion in + * `hasScope`, so it must stay a pure shape test with no allow-list. */ +export function isMcpWriteScope(scope: ApiKeyScope): boolean { + return scope.startsWith(MCP_WRITE_SCOPE_PREFIX); +} + /** * What a key with no persisted `scopes` field is treated as. * @@ -41,9 +83,41 @@ export const LEGACY_DEFAULT_SCOPES: readonly ApiKeyScope[] = [CHAT_WRITE_SCOPE]; /** `:`, lowercase, or the bare global wildcard. */ const SCOPE_PATTERN = /^[a-z][a-z0-9_-]*:[a-z][a-z0-9_-]*$/; +/** + * W2-3 — the ONLY three-segment shape admitted: `mcp:write:`. + * + * Written as a literal `mcp:write:` prefix rather than a generic + * `::` rule on purpose. A generic three-segment rule would quietly + * legalize every `foo:bar:baz` string an operator mistypes, and each such + * string would then be a scope that validates, persists, and grants nothing — + * indistinguishable from a revoked key at debug time. `` reuses the same + * character class the other segments use, so a tool name that cannot appear + * here cannot be granted at all (fail closed, not fail open). + */ +const MCP_WRITE_SCOPE_PATTERN = /^mcp:write:[a-z][a-z0-9_-]*$/; + +/** + * The bare two-segment `mcp:write`, rejected outright. + * + * It is a perfectly well-formed two-segment scope, so `SCOPE_PATTERN` accepts + * it — and it is the single most likely thing an operator types when they mean + * "let this key write". It would validate, persist, and grant NOTHING (no write + * check ever asks for it), which is indistinguishable from a revoked key at + * debug time. Rejecting it turns a silent misconfiguration into an error at the + * moment of the mistake. There is deliberately no class-wide write scope to + * point them at instead: writes are per tool, by design. + */ +const REJECTED_SCOPES: readonly string[] = ['mcp:write']; + export function isValidScope(value: unknown): value is ApiKeyScope { if (typeof value !== 'string') return false; - return value === WILDCARD_SCOPE || SCOPE_PATTERN.test(value); + if (value === WILDCARD_SCOPE) return true; + if (REJECTED_SCOPES.includes(value)) return false; + if (MCP_WRITE_SCOPE_PATTERN.test(value)) return true; + // Checked LAST and unchanged: a `mcp:write:x` string has two colons and + // never matched `SCOPE_PATTERN` anyway, so nothing that used to validate + // stops validating and nothing new slips through the two-segment rule. + return SCOPE_PATTERN.test(value); } /** Grants nothing. Every `hasScope` check against it is false. */ @@ -143,11 +217,36 @@ export function assertValidScopes(scopes: readonly unknown[]): readonly ApiKeySc return Array.from(new Set(scopes as readonly ApiKeyScope[])); } -/** True when `granted` covers `required` — exact match, or the global `*`. */ +/** + * True when `granted` covers `required` — exact match, or the global `*`. + * + * W2-3 carves ONE exception out of the wildcard: a `mcp:write:` scope is + * satisfied by an exact match and by nothing else. The exception lives HERE, + * inside the single scope-matching primitive, rather than in a second + * `hasWriteScope` function the public-MCP route is expected to remember to + * call. A parallel matcher is a matcher someone eventually forgets: the wrong + * call would still compile, still typecheck, and still return `true` for `*` — + * quietly granting an internet-facing write. There is one matcher, and it is + * correct for every caller including `requireApiKey`'s own `opts.scope` gate. + * + * `hasWriteScope` below exists only as an intention-revealing alias; it adds no + * behavior, so using the wrong one of the two is not a security event. + */ export function hasScope( granted: readonly ApiKeyScope[] | undefined, required: ApiKeyScope, ): boolean { if (!granted) return false; + if (isMcpWriteScope(required)) return granted.includes(required); return granted.includes(WILDCARD_SCOPE) || granted.includes(required); } + +/** True when `granted` explicitly names the write capability for `toolName`. + * Intention-revealing alias for `hasScope(granted, mcpWriteScope(tool))` — + * see the wildcard note on `hasScope`. */ +export function hasWriteScope( + granted: readonly ApiKeyScope[] | undefined, + toolName: string, +): boolean { + return hasScope(granted, mcpWriteScope(toolName)); +} diff --git a/middleware/packages/harness-api-key-auth/src/index.ts b/middleware/packages/harness-api-key-auth/src/index.ts index 45216852..e8d6dde9 100644 --- a/middleware/packages/harness-api-key-auth/src/index.ts +++ b/middleware/packages/harness-api-key-auth/src/index.ts @@ -22,8 +22,14 @@ export { CHAT_WRITE_SCOPE, DENY_ALL_SCOPES, hasScope, + hasWriteScope, + isMcpWriteScope, isValidScope, LEGACY_DEFAULT_SCOPES, + MCP_INVOKE_SCOPE, + MCP_LIST_SCOPE, + MCP_WRITE_SCOPE_PREFIX, + mcpWriteScope, normalizeScopes, WILDCARD_SCOPE, type ApiKeyScope, diff --git a/middleware/packages/harness-channel-sdk/src/chatAgent.ts b/middleware/packages/harness-channel-sdk/src/chatAgent.ts index ba60cdbc..789725cb 100644 --- a/middleware/packages/harness-channel-sdk/src/chatAgent.ts +++ b/middleware/packages/harness-channel-sdk/src/chatAgent.ts @@ -112,6 +112,56 @@ export interface PendingUserChoice { options: Array<{ label: string; value: string }>; } +/** + * One free-text field an MCP server asked the human to fill in (#544 W2-1). + * Mirrors the kernel-side `McpInputField`. + */ +export interface McpInputCardField { + /** Machine name — the key the value travels back to the server under. */ + name: string; + /** Display label; fall back to `name` when the server sent none. */ + label?: string; + description?: string; + /** + * Render masked. ADVISORY ONLY: the value still crosses the wire to the + * third-party server verbatim. A channel that cannot mask input must not + * pretend it did. + */ + secret?: boolean; + required?: boolean; +} + +/** + * Pending mid-call input request from an MCP tool (#544 W2-1, MRTR + * `resultType: "input_required"`). Populated when the orchestrator + * short-circuited the turn so the channel can collect the fields; the answer + * arrives as a fresh turn carrying `MCP_INPUT_REPLY_PREFIX`. + * + * A SIBLING of {@link PendingUserChoice}, deliberately not a reuse: a choice + * card is 2-4 mutually exclusive buttons chosen by the model, this is N free-text + * fields demanded by a third-party server. Collapsing them would force one of + * the two into a shape it does not have. + * + * ## `serverName` is mandatory to render + * + * An MCP server can now make omadia display arbitrary prose and collect + * arbitrary free text mid-turn. Without naming the asker, a hostile server could + * phish credentials through a card the user reads as omadia's own UI. Every + * surface — rich card or plain-text fallback — MUST attribute the request. + */ +export interface PendingMcpInputCard { + /** Opaque id the answer must carry back. Single-use, TTL-bounded. */ + correlationId: string; + /** Operator-configured display name of the asking MCP server. Render it. */ + serverName: string; + serverId: string; + /** The MCP tool that asked. */ + toolName: string; + /** Server-supplied prose shown above the fields, when it sent any. */ + prompt?: string; + fields: McpInputCardField[]; +} + /** Slot-picker card scheduled by `find_free_slots`. Mirrors the kernel-side * `PendingSlotCard` from `middleware/src/tools/findFreeSlotsTool.ts`. */ export interface PendingSlotCard { @@ -396,6 +446,18 @@ export interface ChatTurnResult { * pre-question text. */ pendingUserChoice?: PendingUserChoice; + /** + * Set when the orchestrator short-circuits because an MCP tool answered + * `resultType: "input_required"` (#544 W2-1). Channels with rich UI render an + * input form; channels without one degrade to a plain-text prompt, exactly as + * the `pendingUserChoice` path already does. A submitted answer fires a fresh + * turn carrying the reply envelope, which the orchestrator resolves and + * replays. Mutually exclusive with `pendingUserChoice` — when both were + * pending in one batch, the choice card wins. + * + * A SIBLING of `pendingUserChoice`, not a reuse: free-text fields, not buttons. + */ + pendingMcpInput?: PendingMcpInputCard; /** * 1-click refinement buttons rendered below the answer. Populated when the * LLM invoked `suggest_follow_ups` during the turn. Clicks fire a fresh @@ -669,6 +731,11 @@ export type ChatStreamEvent = * See ChatTurnResult.pendingUserChoice for semantics. */ pendingUserChoice?: PendingUserChoice; + /** + * #544 W2-1 — MCP mid-call input request. Sibling of `pendingUserChoice` + * on the same `done` event; see ChatTurnResult.pendingMcpInput. + */ + pendingMcpInput?: PendingMcpInputCard; /** 1-click refinement buttons attached to the answer; see * ChatTurnResult.followUpOptions for semantics. */ followUpOptions?: FollowUpOption[]; diff --git a/middleware/packages/harness-channel-sdk/src/index.ts b/middleware/packages/harness-channel-sdk/src/index.ts index 2f8683b5..5ecf20b9 100644 --- a/middleware/packages/harness-channel-sdk/src/index.ts +++ b/middleware/packages/harness-channel-sdk/src/index.ts @@ -89,6 +89,9 @@ export type { OutgoingFileAttachment, PendingUserChoice, PendingSlotCard, + // #544 W2-1 — MCP mid-call input request. + McpInputCardField, + PendingMcpInputCard, PendingRoutineList, AgentMeta, } from './chatAgent.js'; @@ -101,6 +104,8 @@ export { toSemanticAnswer } from './toSemanticAnswer.js'; // #332 Layer 1 — plain-text fallback so even a minimal connector (no rich-card // UI) can append a readable, harness-sourced consulted-agents footer line. export { agentsConsultedFooterText } from './toSemanticAnswer.js'; +// #544 W2-1 — plain-text fallback for channels without form support. +export { withMcpInputPrompt } from './toSemanticAnswer.js'; // #332 Layer 1 (gap-closure) — shared derivation so streaming clients // (web-ui) and non-streaming `toSemanticAnswer` callers (Teams et al.) build @@ -117,6 +122,8 @@ export type { OutgoingChoiceCard, OutgoingSlotPicker, OutgoingTopicAsk, + // #544 W2-1 — MCP mid-call input form. + OutgoingMcpInputForm, CaptureDisclosure, AgentConsultation, DelegatedAnswer, diff --git a/middleware/packages/harness-channel-sdk/src/outgoing.ts b/middleware/packages/harness-channel-sdk/src/outgoing.ts index d74032cb..8945443c 100644 --- a/middleware/packages/harness-channel-sdk/src/outgoing.ts +++ b/middleware/packages/harness-channel-sdk/src/outgoing.ts @@ -246,7 +246,43 @@ export type OutgoingInteractive = | OutgoingChoiceCard | OutgoingSlotPicker | OutgoingTopicAsk - | OutgoingRoutineList; + | OutgoingRoutineList + | OutgoingMcpInputForm; + +/** + * Mid-call input request from an MCP tool (#544 W2-1). Connector renders a form + * of free-text fields; the submitted values ride back as the next user message + * in the `MCP_INPUT_REPLY_PREFIX` envelope, which the orchestrator resolves. + * + * Distinct from {@link OutgoingChoiceCard} because the shapes genuinely differ: + * N free-text fields demanded by a third party, versus 2-4 mutually exclusive + * options the model chose. + * + * `serverName` MUST be rendered. A server can put arbitrary prose in `prompt` + * and collect arbitrary text; a card that does not name the asker lets a hostile + * server phish credentials behind omadia's own chrome. Connectors that cannot + * render a form MUST still show the plain-text prompt `toSemanticAnswer` folds + * into `text` — never silently drop the request. + */ +export interface OutgoingMcpInputForm { + kind: 'mcp_input'; + /** Opaque single-use id the answer must carry back. */ + correlationId: string; + /** Display name of the MCP server making the request. Mandatory on screen. */ + serverName: string; + serverId: string; + toolName: string; + /** Server-supplied prose, when it sent any. Untrusted text — render as text. */ + prompt?: string; + fields: Array<{ + name: string; + label?: string; + description?: string; + /** Advisory masking hint; the value still reaches the server verbatim. */ + secret?: boolean; + required?: boolean; + }>; +} /** * Multi-option question card. Connector renders as buttons / select / radio. diff --git a/middleware/packages/harness-channel-sdk/src/toSemanticAnswer.ts b/middleware/packages/harness-channel-sdk/src/toSemanticAnswer.ts index b5c4fcee..1a133664 100644 --- a/middleware/packages/harness-channel-sdk/src/toSemanticAnswer.ts +++ b/middleware/packages/harness-channel-sdk/src/toSemanticAnswer.ts @@ -1,4 +1,8 @@ -import type { ChatTurnResult, RunTracePayload } from './chatAgent.js'; +import type { + ChatTurnResult, + PendingMcpInputCard, + RunTracePayload, +} from './chatAgent.js'; import type { AgentConsultation, OutgoingAttachment, @@ -86,6 +90,30 @@ export function deriveAgentsConsulted( * and the orchestrator-plugin can both import from the same package without * pulling in kernel-internal symbols. */ +/** + * #544 W2-1 — plain-text rendering of a pending MCP input request, appended to + * the answer so a connector with no form support still tells the user what is + * being asked and, crucially, BY WHOM. + * + * Field names and labels only, never values (there are none yet) — and the + * server's own `prompt` is included but clearly attributed, so untrusted prose + * cannot read as omadia speaking. + */ +export function withMcpInputPrompt( + answer: string, + card: PendingMcpInputCard | undefined, +): string { + if (!card) return answer; + const fields = card.fields + .map((f) => `- ${f.label ?? f.name}${f.required === true ? ' (erforderlich)' : ''}`) + .join('\n'); + const block = + `**Der MCP-Server "${card.serverName}" fragt für "${card.toolName}" nach zusätzlichen Angaben.**` + + (card.prompt !== undefined ? `\n\n> ${card.prompt}` : '') + + `\n\n${fields}`; + return answer.trim().length > 0 ? `${answer}\n\n${block}` : block; +} + export function toSemanticAnswer(r: ChatTurnResult): SemanticAnswer { // Inline images (diagrams) and downloadable files (office docs) flow into // one channel-agnostic attachment array. Diagrams keep their `image` kind; @@ -126,6 +154,22 @@ export function toSemanticAnswer(r: ChatTurnResult): SemanticAnswer { value: o.value, })), }; + } else if (r.pendingMcpInput) { + interactive = { + kind: 'mcp_input', + correlationId: r.pendingMcpInput.correlationId, + serverName: r.pendingMcpInput.serverName, + serverId: r.pendingMcpInput.serverId, + toolName: r.pendingMcpInput.toolName, + ...(r.pendingMcpInput.prompt ? { prompt: r.pendingMcpInput.prompt } : {}), + fields: r.pendingMcpInput.fields.map((f) => ({ + name: f.name, + ...(f.label !== undefined ? { label: f.label } : {}), + ...(f.description !== undefined ? { description: f.description } : {}), + ...(f.secret === true ? { secret: true } : {}), + ...(f.required === true ? { required: true } : {}), + })), + }; } else if (r.pendingSlotCard) { interactive = { kind: 'slots', @@ -163,7 +207,14 @@ export function toSemanticAnswer(r: ChatTurnResult): SemanticAnswer { const agentsConsulted = deriveAgentsConsulted(r.runTrace); return { - text: r.answer, + // #544 W2-1 — honest degradation. `interactive` above is the rich form, but + // a connector that cannot render one would otherwise show the model's + // (usually empty) pre-question prose and give the user NO indication that a + // named server is blocked waiting for input. So the prompt is also folded + // into `text`, which every connector MUST render. The rich-card channels + // are expected to prefer `interactive`; the duplication is the price of not + // silently swallowing the request. + text: withMcpInputPrompt(r.answer, r.pendingMcpInput), ...(verifier ? { verifier } : {}), ...(agentsConsulted && agentsConsulted.length > 0 ? { agentsConsulted } diff --git a/middleware/packages/harness-memory-postgres/src/postgresMemoryStore.ts b/middleware/packages/harness-memory-postgres/src/postgresMemoryStore.ts index 3536ee80..d663b75b 100644 Binary files a/middleware/packages/harness-memory-postgres/src/postgresMemoryStore.ts and b/middleware/packages/harness-memory-postgres/src/postgresMemoryStore.ts differ diff --git a/middleware/packages/harness-orchestrator-extras/src/recallRelevanceJudge.ts b/middleware/packages/harness-orchestrator-extras/src/recallRelevanceJudge.ts index 2fec9e0d..fcbcbd86 100644 Binary files a/middleware/packages/harness-orchestrator-extras/src/recallRelevanceJudge.ts and b/middleware/packages/harness-orchestrator-extras/src/recallRelevanceJudge.ts differ diff --git a/middleware/packages/harness-orchestrator/src/buildOrchestrator.ts b/middleware/packages/harness-orchestrator/src/buildOrchestrator.ts index 1169bc50..576a0207 100644 --- a/middleware/packages/harness-orchestrator/src/buildOrchestrator.ts +++ b/middleware/packages/harness-orchestrator/src/buildOrchestrator.ts @@ -45,6 +45,10 @@ import type { NativeToolRegistry } from './nativeToolRegistry.js'; import type { ModelRoutingConfig } from './modelRouter.js'; import { Orchestrator, type OrchestratorPersonaSkill } from './orchestrator.js'; import type { DirectLineStickyStore } from './directLineSticky.js'; +import type { + McpInputReplayer, + PendingMcpInputStore, +} from './mcp/pendingMcpInput.js'; import { CliChatAgent } from './cliChatAgent.js'; import { ToolDispatchService } from './toolDispatchService.js'; import { OrchestratorMemoryNamespacer } from './orchestratorMemoryNamespacer.js'; @@ -109,6 +113,18 @@ export interface OrchestratorDeps { * conversation whenever an operator tweaked something unrelated. */ readonly directLineStickyStore?: DirectLineStickyStore; + /** + * W2-1 (#544) — process-shared MCP pending-input store + replayer. + * + * Deps, not per-Agent config, for the SAME reason as + * `directLineStickyStore`: the registry replaces an Orchestrator instance on + * any config diff, and a per-instance store would drop every parked call + * whenever an operator changed something unrelated — after the user had + * already seen the card. Must be the same store instance the kernel's + * `McpManager` writes to. + */ + readonly pendingMcpInput?: PendingMcpInputStore; + readonly mcpInputReplay?: McpInputReplayer; /** Late-bound `responseGuard@1` lookup (see `OrchestratorOptions`). */ readonly responseGuard: () => ResponseGuardService | undefined; /** Late-bound `privacy.redact@1` lookup (see `OrchestratorOptions`). */ @@ -295,6 +311,14 @@ export function buildOrchestratorForAgent( ...(deps.excerptExtractor ? { excerptExtractor: deps.excerptExtractor } : {}), chatParticipantsTool, askUserChoiceTool, + // W2-1 (#544) — both or neither: a store with no replayer would park calls + // the user can answer but nothing can deliver. + ...(deps.pendingMcpInput && deps.mcpInputReplay + ? { + pendingMcpInput: deps.pendingMcpInput, + mcpInputReplay: deps.mcpInputReplay, + } + : {}), suggestFollowUpsTool, ...(findFreeSlotsTool ? { findFreeSlotsTool } : {}), ...(bookMeetingTool ? { bookMeetingTool } : {}), diff --git a/middleware/packages/harness-orchestrator/src/index.ts b/middleware/packages/harness-orchestrator/src/index.ts index 75acc579..60e7f45d 100644 --- a/middleware/packages/harness-orchestrator/src/index.ts +++ b/middleware/packages/harness-orchestrator/src/index.ts @@ -103,6 +103,8 @@ export type { CanvasPos, McpCallLogRow, McpConfigField, + McpDelegation, + McpOAuthClientAcquisition, McpRegistryRow, McpServerInput, McpServerRow, @@ -125,23 +127,82 @@ export type { ToolGrantRow, } from './registry/agentGraphStore.js'; export { + DEPRECATED_MCP_TRANSPORTS, + isDeprecatedMcpTransport, McpManager, + MCP_RESULT_TYPE_INPUT_REQUIRED, + REPLAY_ARG_KEY, + extractStructured, + isInputRequiredResult, mcpNativeHandler, mcpNativeToolName, mcpToolToLocalSubAgentTool, mcpToolToNativeSpec, + renderToolResult, + // W3-A — the inner half of the timeout hierarchy; see the ORDERING INVARIANT + // block next to `DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS`. + resolveMcpCallTimeouts, + // W4 — attempts per `callTool`. The timeout hierarchy reasons about the + // retry-inclusive worst case, so the real number has to be readable. + MCP_CALL_MAX_ATTEMPTS, } from './mcp/mcpClient.js'; export type { + DeprecatedMcpTransport, McpAuthProvider, McpCallerKind, McpCallGuard, McpCallLogEntry, McpCallObserver, + McpCallOutcome, + McpInputRequiredSidecar, McpManagerOptions, McpServerConfig, + McpSidecarIdentity, + McpSidecarKind, + McpSidecarPayload, + McpStructuredOutputSidecar, + McpStructuredSink, McpToolDescriptor, McpTransportKind, } from './mcp/mcpClient.js'; +// W2-1 (#544) — MRTR mid-call user input. +export { + InMemoryPendingMcpInputStore, + MCP_INPUT_MAX_REPLAY_DEPTH, + MCP_INPUT_REPLY_PREFIX, + MCP_INPUT_REQUEST_MAX_FIELDS, + MCP_INPUT_REQUIRED_SENTINEL_PREFIX, + PENDING_MCP_INPUT_MAX_ENTRIES, + PENDING_MCP_INPUT_TTL_MS, + claimMcpInputFromResults, + extractMcpInputPrompt, + formatMcpInputReply, + mcpInputMalformedError, + mcpInputReplayCappedError, + mcpInputRequiredSentinel, + mcpInputUnsupportedError, + mcpInputReplyLabel, + parseMcpInputReply, + parseMcpInputRequests, + parseMcpInputSentinel, + resetSharedMcpInputWiring, + setSharedMcpInputReplayer, + sharedMcpInputReplayer, + sharedPendingMcpInputStore, +} from './mcp/pendingMcpInput.js'; +export type { + InMemoryPendingMcpInputStoreOptions, + McpInputField, + McpInputParseFailure, + McpInputParseOutcome, + McpInputReplayer, + McpInputReply, + PendingMcpInput, + PendingMcpInputKey, + PendingMcpInputOwner, + PendingMcpInputStore, + PutPendingMcpInputResult, +} from './mcp/pendingMcpInput.js'; export { buildSubAgentDomainTools, mcpToolNameFromRef, @@ -168,7 +229,16 @@ export type { } from './buildOrchestrator.js'; // Orchestrator class + options -export { Orchestrator, parseToolEmittedChoice } from './orchestrator.js'; +export { + Orchestrator, + parseToolEmittedChoice, + // W3-A — the outer half of the timeout hierarchy; see the ORDERING INVARIANT + // block next to `DEFAULT_TOOL_DISPATCH_TIMEOUT_MS`. + resolveToolDispatchTimeoutMs, + // …and its production enforcement. Call at boot: a deployment whose timeout + // knobs invert the hierarchy must not start quietly. + assertTimeoutHierarchy, +} from './orchestrator.js'; export type { OrchestratorOptions } from './orchestrator.js'; // #332 Layer 2 — Direct Line directive parsing & target resolution (exported @@ -254,8 +324,39 @@ export type { export { ToolDispatchService } from './toolDispatchService.js'; export type { DispatchableToolSpec, + ToolDispatchContentOrigin, ToolDispatchResult, + ToolDispatchCallerContext, + ToolDispatchOptions, } from './toolDispatchService.js'; +export { + ToolIdempotencyStore, + currentIdempotencyScope, + runWithIdempotencyScope, + fingerprintToolInput, + idempotencyCacheKey, + idempotencyConflictMessage, + DEFAULT_IDEMPOTENCY_TTL_MS, + DEFAULT_IDEMPOTENCY_MAX_ENTRIES, +} from './toolIdempotency.js'; +export type { + ToolIdempotencyScope, + ToolIdempotencyResult, + ToolIdempotencyOutcome, +} from './toolIdempotency.js'; +export { + currentDispatchCaller, + runWithDispatchCaller, +} from './toolCallerContext.js'; +// W2-3 (#542) — the public MCP endpoint dispatches OUTSIDE any turn, so it must +// build and supply a privacy handle explicitly (the ambient `turnContext` +// fallback is `undefined` there). It also needs the intern-exemption allowlist: +// an exempt tool's result is handed over IN CLEAR by design, which is correct +// for the agent's own infra tools inside a turn and unacceptable for an +// internet-facing caller — so the endpoint refuses to serve those names at all. +export { createPrivacyTurnHandle } from './privacyHandle.js'; +export type { PrivacyTurnHandle } from './privacyHandle.js'; +export { INTERN_EXEMPT_TOOLS, isInternExemptTool } from './privacyInternPolicy.js'; export { LoopbackMcpServer } from './loopbackMcpServer.js'; export type { LoopbackMcpServerDeps, @@ -315,6 +416,8 @@ export { turnContext, today, buildDateHeader, + // Teardown failures are reported, never thrown — see `runGeneratorInContext`. + onTurnTeardownError, } from './turnContext.js'; export type { TurnContextValue } from './turnContext.js'; export { @@ -427,3 +530,60 @@ export type { VerifierResultSummary, } from '@omadia/channel-sdk'; export { toSemanticAnswer } from '@omadia/channel-sdk'; + +// W2-2 (issue #543, rescoped) — the generic long-running task seam: any tool can +// be marked `longRunning` and get the non-blocking `_start`/`_status`/ +// `_list` triple plus a streaming status card, instead of blocking a chat turn. +// Deferred sub-agent dispatch is the in-package implementor +// (`tasks/subAgentTaskTool.ts`); consumers supply their own `TaskStore`. +export { + TASK_LIFECYCLE_STATUSES, + TERMINAL_TASK_STATUSES, + TASK_LEASE_UUID_RE, + TaskLeaseLostError, + isTaskLifecycleStatus, + isTerminalTaskStatus, +} from './tasks/taskTypes.js'; +export type { + NewTaskInput, + TaskCardPayload, + TaskDescriptor, + TaskEventRecord, + TaskLifecycleStatus, + TaskListFilter, + TaskReadStore, + TaskReapOptions, + TaskReapResult, + TaskStore, + TerminalTaskPatch, +} from './tasks/taskTypes.js'; +export { InMemoryTaskStore } from './tasks/inMemoryTaskStore.js'; +export type { InMemoryTaskStoreOptions } from './tasks/inMemoryTaskStore.js'; +export { + defineLongRunningTool, + describeDeferredPrivacyPosture, + longRunningToolNames, +} from './tasks/longRunningTool.js'; +export type { + LongRunningToolDefinition, + LongRunningToolHandle, + LongRunningToolRegistration, + TaskExecutionHandle, + TaskExecutor, + // W4 — a terminal outcome a runner produced but could not record, because the + // reaper had already written its own row. The only place it survives. + TaskOutcomeLostRecord, +} from './tasks/longRunningTool.js'; +export { + DEFAULT_TASK_PURGE_TERMINAL_AFTER_MS, + DEFAULT_TASK_REAP_INTERVAL_MS, + DEFAULT_TASK_STALE_AFTER_MS, + runTaskReaperOnce, + startTaskReaper, +} from './tasks/taskReaper.js'; +export type { TaskReaperOptions } from './tasks/taskReaper.js'; +export { + SUB_AGENT_TASK_KIND_PREFIX, + createLongRunningSubAgentTool, +} from './tasks/subAgentTaskTool.js'; +export type { LongRunningSubAgentToolOptions } from './tasks/subAgentTaskTool.js'; diff --git a/middleware/packages/harness-orchestrator/src/loopbackMcpServer.ts b/middleware/packages/harness-orchestrator/src/loopbackMcpServer.ts index 3d37f4a7..7ef7f0e1 100644 --- a/middleware/packages/harness-orchestrator/src/loopbackMcpServer.ts +++ b/middleware/packages/harness-orchestrator/src/loopbackMcpServer.ts @@ -7,7 +7,6 @@ * the dispatch service and the MCP SDK this package already depends on. */ -import { randomUUID } from 'node:crypto'; import { createServer, type IncomingMessage, @@ -28,6 +27,7 @@ import type { DispatchableToolSpec, ToolDispatchService, } from './toolDispatchService.js'; +import { sortByToolName } from './toolOrdering.js'; const MAX_REQUEST_BYTES = 8 * 1024 * 1024; @@ -49,8 +49,6 @@ export interface LoopbackMcpServerHandle { export class LoopbackMcpServer { private http?: HttpServer; - private transport?: StreamableHTTPServerTransport; - private mcp?: McpServer; private started = false; constructor(private readonly deps: LoopbackMcpServerDeps) { @@ -68,39 +66,6 @@ export class LoopbackMcpServer { throw new Error('LoopbackMcpServer: already started'); } - this.mcp = new McpServer( - { - name: this.deps.serverName ?? 'omadia-loopback', - version: this.deps.serverVersion ?? '0.0.0', - }, - { capabilities: { tools: {} } }, - ); - - this.mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: this.deps.tools.map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.input_schema, - })), - })); - - this.mcp.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - const result = await this.deps.dispatch.dispatch(name, args ?? {}); - return { - content: [{ type: 'text' as const, text: result.content }], - ...(result.isError ? { isError: true } : {}), - }; - }); - - // Stateless-ish loopback transport; JSON responses simplify the client and - // session IDs remain required by the protocol. - this.transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, - }); - await this.mcp.connect(this.transport); - this.http = createServer((req, res) => { void this.handleHttp(req, res); }); @@ -134,9 +99,6 @@ export class LoopbackMcpServer { return; } - await this.mcp?.close().catch(() => {}); - await this.transport?.close().catch(() => {}); - if (this.http) { await new Promise((resolve) => { this.http?.close(() => resolve()); @@ -144,11 +106,68 @@ export class LoopbackMcpServer { } this.http = undefined; - this.transport = undefined; - this.mcp = undefined; this.started = false; } + /** + * Builds a fresh MCP server + transport pair for a single HTTP request. + * + * W1-2 — `sessionIdGenerator: undefined` selects the SDK's stateless mode: + * no session id is issued and no session validation happens, so a client may + * skip the `initialize` handshake and never send `Mcp-Session-Id`. The SDK + * enforces the other half of that contract — a stateless transport throws + * "Stateless transport cannot be reused across requests" on its second use — + * so the transport (and the `Server` bound to it) is per-request by + * construction, matching the SDK's own stateless example. + * + * Cost is negligible: both objects are pure in-memory handler tables with no + * I/O, and this server sees a handful of requests per CLI turn. Nothing here + * held cross-request state worth keeping — the tool list and the dispatch + * service are owned by `deps`, and the bearer token is what actually scopes + * access. `enableJsonResponse` stays on: JSON replies keep the client simple + * and also guarantee the response is fully written by the time + * `handleRequest` resolves, which is what makes per-request teardown safe. + */ + private createRequestScopedServer(): { + mcp: McpServer; + transport: StreamableHTTPServerTransport; + } { + const mcp = new McpServer( + { + name: this.deps.serverName ?? 'omadia-loopback', + version: this.deps.serverVersion ?? '0.0.0', + }, + { capabilities: { tools: {} } }, + ); + + // W0-3 — advertise name-sorted. `ToolDispatchService` already sorts, but + // `deps.tools` is caller-supplied, so sorting here makes the wire order a + // property of this server rather than a convention every caller must know. + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: sortByToolName(this.deps.tools).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.input_schema, + })), + })); + + mcp.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const result = await this.deps.dispatch.dispatch(name, args ?? {}); + return { + content: [{ type: 'text' as const, text: result.content }], + ...(result.isError ? { isError: true } : {}), + }; + }); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + + return { mcp, transport }; + } + private async handleHttp( req: IncomingMessage, res: ServerResponse, @@ -173,20 +192,45 @@ export class LoopbackMcpServer { return; } - try { - const transport = this.transport; - if (!transport) { - throw new McpError(ErrorCode.InternalError, 'Transport not started'); - } + if (!this.started) { + throw new McpError(ErrorCode.InternalError, 'Transport not started'); + } - if (req.method === 'POST') { - const rawBody = await this.readBody(req); - const parsedBody = rawBody.length > 0 ? JSON.parse(rawBody) : undefined; - await transport.handleRequest(req, res, parsedBody); - return; - } + // W1-2 — POST only. The MCP spec makes the GET standalone SSE stream + // optional and blesses 405 when a server does not offer one, and this + // server has nothing to deliver over it: `enableJsonResponse` answers every + // request inline and there are no server-initiated notifications. + // + // Declining it explicitly also avoids a leak introduced by the per-request + // transport: a GET opens a stream that never ends, so `handleRequest` never + // resolves, so the `finally` below never runs and the request-scoped + // server/transport pair stays alive until the client disconnects. Under the + // old stateful transport a session-less GET was simply rejected with 400, + // so nothing regresses here. + if (req.method !== 'POST') { + res.writeHead(405, { + 'Content-Type': 'application/json', + Allow: 'POST', + }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method Not Allowed' }, + id: null, + }), + ); + return; + } - await transport.handleRequest(req, res); + // Read the body BEFORE building the per-request server so an oversized + // POST still fails with 413 without paying for the handler wiring. + let session: ReturnType | undefined; + try { + const parsedBody = await this.parsePostBody(req); + + session = this.createRequestScopedServer(); + await session.mcp.connect(session.transport); + await session.transport.handleRequest(req, res, parsedBody); } catch (error) { if (res.headersSent) { res.end(); @@ -215,9 +259,22 @@ export class LoopbackMcpServer { id: null, }), ); + } finally { + // A stateless transport is single-use; dropping it here is what keeps + // the next request from hitting the SDK's reuse guard. Safe at this + // point because `enableJsonResponse` means the response is already + // fully written when `handleRequest` resolves. + await session?.transport.close().catch(() => {}); + await session?.mcp.close().catch(() => {}); } } + /** Reads and JSON-parses a POST body, or `undefined` when the body is empty. */ + private async parsePostBody(req: IncomingMessage): Promise { + const rawBody = await this.readBody(req); + return rawBody.length > 0 ? JSON.parse(rawBody) : undefined; + } + private async readBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; let totalBytes = 0; diff --git a/middleware/packages/harness-orchestrator/src/mcp/mcpClient.ts b/middleware/packages/harness-orchestrator/src/mcp/mcpClient.ts index d3ed9d0c..a3cc7b5e 100644 --- a/middleware/packages/harness-orchestrator/src/mcp/mcpClient.ts +++ b/middleware/packages/harness-orchestrator/src/mcp/mcpClient.ts @@ -17,7 +17,7 @@ * string so a tool failure degrades the turn instead of killing it. */ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; @@ -36,6 +36,18 @@ import type { } from '@omadia/plugin-api'; import { turnContext } from '../turnContext.js'; +import { currentIdempotencyScope } from '../toolIdempotency.js'; +import { + MCP_INPUT_MAX_REPLAY_DEPTH, + extractMcpInputPrompt, + mcpInputMalformedError, + mcpInputReplayCappedError, + mcpInputRequiredSentinel, + mcpInputUnsupportedError, + parseMcpInputRequests, + type PendingMcpInput, + type PendingMcpInputStore, +} from './pendingMcpInput.js'; /** * Relaxed CallToolResult schema: the MCP spec says `structuredContent` MUST be a @@ -43,19 +55,82 @@ import { turnContext } from '../turnContext.js'; * an array. The SDK's strict schema rejects the entire result, and callTool then * throws — which we surface as "-32000 Connection closed", making every call on * that server look like a dead connection. Accepting any `structuredContent` - * keeps well-formed servers unchanged while tolerating this one deviation; we - * only read `content`/`isError` downstream anyway. + * keeps well-formed servers unchanged while tolerating this one deviation. + * + * Issue #547 (W1-3): `structuredContent` is now also read out-of-band via + * `extractStructured` and handed to `McpManagerOptions.structuredSink`. The + * lenient schema is what makes that possible for off-spec (array-valued) + * payloads too — the sink carries whatever the server sent, unnormalised. + * + * Issue #544 (W2-1): `resultType` + `inputRequests` (MRTR mid-call user input) + * are declared here too. Both are readable off the SHIPPED SDK 1.29.0 — no + * version bump, and no dependency on the `@modelcontextprotocol/{core,client, + * server}@2.0.0` family (#540). + * + * Be precise about what the declaration buys, because it is NOT "makes the + * fields arrive": SDK 1.29.0's `CallToolResultSchema` derives from + * `ResultSchema`, which is `.passthrough()`, so an unmodelled key already + * survives `parse` at runtime. Verified, and pinned by a characterization test + * in `mcpPendingInput.test.ts`. Declaring them explicitly buys two things: + * 1. They are typed and intentional rather than an unnamed passthrough + * residue, so a reader can see what we consume off the wire. + * 2. The behaviour stops being hostage to an SDK internal. Passthrough is not + * part of the MRTR contract, and this file already carries the scar of a + * strict-vs-lenient result schema breaking every call on a server + * (`structuredContent`, above); a future SDK that tightens it would break + * MRTR silently instead of loudly. + * Both are typed loosely on purpose — the MRTR shape is not final, so + * validation lives in `parseMcpInputRequests` where a failure can degrade to a + * plain tool error instead of rejecting the whole result. */ // Cast back to the base schema type: the SDK's callTool overload is typed to the // strict CallToolResultSchema, but our runtime schema only *widens* what parses -// (any structuredContent), so it is a safe superset. We never read -// structuredContent downstream — only `content`/`isError`. +// (any structuredContent, optional resultType/inputRequests), so it is a safe +// superset. const LENIENT_CALL_TOOL_RESULT_SCHEMA = CallToolResultSchema.extend({ structuredContent: z.unknown().optional(), + resultType: z.string().optional(), + inputRequests: z.unknown().optional(), }) as unknown as typeof CallToolResultSchema; +/** MRTR result type meaning "I need more information from the human" (#544). */ +export const MCP_RESULT_TYPE_INPUT_REQUIRED = 'input_required'; + export type McpTransportKind = 'stdio' | 'http' | 'sse'; +/** + * Transports the MCP specification has formally deprecated (issue #541). + * + * The MCP 2026-07-28 revision reclassifies the legacy HTTP+SSE transport + * (two endpoints: `GET /sse` for the event stream plus a separate POST + * endpoint for messages) as **Deprecated**, with a minimum 12-month removal + * window. Streamable HTTP (our `'http'`) is the migration target. + * + * omadia therefore *discourages* `'sse'` for NEW registrations — the operator + * picker hides it behind a "show deprecated transports" toggle, and the + * marketplace importer prefers an `http` remote when a catalog entry offers + * both. Nothing is hard-blocked: the removal window is open, existing rows + * keep working unchanged (`SSEClientTransport` stays wired in + * `McpManager.transportFor`), and the `agent_mcp_servers.transport` CHECK + * constraint still accepts `'sse'`, so a legacy server can be re-created. + * + * This array is the single source of truth for "which transports are + * deprecated" — the API serializer, the marketplace importer, and the web-ui + * all derive from it rather than hard-coding `'sse'`. + */ +export const DEPRECATED_MCP_TRANSPORTS = ['sse'] as const; + +/** A transport listed in {@link DEPRECATED_MCP_TRANSPORTS}. */ +export type DeprecatedMcpTransport = (typeof DEPRECATED_MCP_TRANSPORTS)[number]; + +/** + * True when `transport` is deprecated by the MCP spec. Takes a plain `string` + * so callers holding an unvalidated DB/catalog value can ask without casting. + */ +export function isDeprecatedMcpTransport(transport: string): boolean { + return (DEPRECATED_MCP_TRANSPORTS as readonly string[]).includes(transport); +} + export interface McpServerConfig { readonly id: string; readonly name: string; @@ -76,12 +151,48 @@ export interface McpToolDescriptor { readonly name: string; readonly description?: string; readonly inputSchema?: Record; + /** + * Issue #547 (W1-3) — the tool's declared `outputSchema` from `tools/list`. + * Never sent to the model (it would only inflate the prompt); it travels with + * the structured-result sidecar so a downstream consumer can render the + * payload against its declared shape. Persisted with the discovered-tool row + * so it survives a restart without re-discovery. + */ + readonly outputSchema?: Record; } -/** Caller taxonomy for the MCP call audit log (epic #459 W2, issue #462). - * Defined once here; skill (#456) and plugin (#458) surfaces identify - * themselves via `turnContext.mcpCallerKind`. */ -export type McpCallerKind = 'agent' | 'subagent' | 'skill' | 'plugin' | 'unattributed'; +/** + * Caller taxonomy for the MCP call audit log (epic #459 W2, issue #462). + * Defined once here; skill (#456) and plugin (#458) surfaces identify + * themselves via `turnContext.mcpCallerKind`. + * + * W2-3 (issue #542) adds `api_key`: a call arriving over the public MCP + * endpoint from a third party holding an API key. It is none of the other five + * — no orchestrator turn, no sub-agent, no plugin — and squeezing it into + * `plugin` or `unattributed` would make the one question that row exists to + * answer ("internal turn, or the internet?") unanswerable from the data. + * Migration 0033 widens the matching `mcp_call_log.caller_kind` CHECK. + */ +export type McpCallerKind = + | 'agent' + | 'subagent' + | 'skill' + | 'plugin' + | 'unattributed' + | 'api_key'; + +/** + * Issue #544 (W2-1) — what actually happened on a call. + * + * The audit trail used to be binary (`ok: true | false`), which MRTR breaks: + * a parked `input_required` call neither succeeded nor failed. Overloading + * either value would misreport it — `ok: false` would put a phantom failure in + * front of operators debugging a healthy server, and a bare `ok: true` would + * claim the tool delivered a result it never delivered. So the truth gets its + * own field, and `ok` keeps its narrower documented meaning: "the call + * completed without failing". + */ +export type McpCallOutcome = 'ok' | 'fail' | 'input_required'; /** One audit entry per `callTool` invocation. Deliberately carries NO tool * arguments — identity and outcome only. */ @@ -92,10 +203,24 @@ export interface McpCallLogEntry { readonly callerKind: McpCallerKind; readonly callerAgent: string | null; readonly turnId: string | null; + /** True unless the call FAILED. An `input_required` park is not a failure — + * read `outcome` to tell it apart from a delivered result. */ readonly ok: boolean; + /** + * W2-1 — the three-valued truth. Optional so persisters that predate #544 + * (and the `mcp_call_log` table, which has no column for it yet) keep + * compiling and simply ignore it; every entry the manager emits sets it. + */ + readonly outcome?: McpCallOutcome; readonly error: string | null; readonly durationMs: number; readonly calledAt: Date; + /** W0-1 — WHOSE authority this call acted under. `callerAgent` names the + * orchestrator; this names the identity its credentials belonged to. The + * literal `unresolved` marks a `per_user` server that had no identity to + * act as (the call fails closed), which is exactly the case an operator + * needs to be able to find in the audit trail. */ + readonly actingIdentity: string | null; } /** Observer invoked after every tool call. Implementations must be fast and @@ -128,6 +253,15 @@ export interface McpAuthProvider { * decision, so the manager needs no OAuth knowledge. */ onAuthFailure(cfg: McpServerConfig): Promise; + /** + * The identity this server's calls act as, for the audit trail (W0-1). Same + * resolution `getToken` uses, exposed separately so EVERY audited call — + * including denied ones and calls to servers with no OAuth at all — records + * who acted. Returns null when the provider cannot attribute the call. + * Optional: providers that predate W0-1 keep working (identity falls back to + * the turn context). + */ + resolveIdentity?(cfg: McpServerConfig): Promise; /** * Secret config values to inject as request headers for this server (epic * #459). Resolved from the Vault per call so secrets never live on the pooled @@ -143,10 +277,88 @@ export interface McpAuthProvider { getConfigEnv?(cfg: McpServerConfig): Promise>; } +// ── out-of-band sidecar (issue #547 W1-3) ─────────────────────────────────── +// +// Everything the model sees still travels as the plain string `callTool` +// returns. Anything richer — an MCP `structuredContent` payload today, an +// `input_required` result type tomorrow (#544 MRTR / W2-1) — leaves the manager +// through this second, out-of-band channel instead of widening the return type. +// +// Widening was ruled out deliberately, for two reasons that are not stylistic: +// 1. `NativeToolHandler = (input: unknown) => Promise` is a published +// plugin contract; every in-tree and out-of-tree plugin implements it. +// 2. The orchestrator gates Privacy Shield masking on +// `typeof result === 'string'`. A non-string result would silently skip +// masking — i.e. bypass the shield entirely. +// The sidecar keeps both invariants intact: no downstream hop changes. + +/** Discriminator for a sidecar payload. W1-3 shaped this as a union precisely + * so W2-1 could add its second member here instead of inventing a parallel + * channel; `'input_required'` is that planned member (#544). */ +export type McpSidecarKind = 'structured_output' | 'input_required'; + +/** Identity carried by every sidecar payload: which turn, which server, which + * tool. `turnId` is null outside a turn (e.g. an operator test-call). */ +export interface McpSidecarIdentity { + readonly serverId: string; + readonly toolName: string; + readonly turnId: string | null; +} + +/** An MCP tool returned a `structuredContent` payload alongside its text. */ +export interface McpStructuredOutputSidecar extends McpSidecarIdentity { + readonly kind: 'structured_output'; + /** The parsed payload exactly as the server sent it — object, or an array for + * off-spec hosted servers. Never a re-parse of the rendered string. */ + readonly structured: unknown; + /** The tool's declared `outputSchema`, when discovery captured one. */ + readonly outputSchema?: Record; +} + +/** + * Issue #544 (W2-1) — an MCP tool answered `resultType: "input_required"` and + * the call has been parked in the {@link PendingMcpInputStore}. Rides the same + * out-of-band channel as the structured-output payload for the same reason: the + * model-facing return stays a plain string (a stable sentinel), so neither the + * `NativeToolHandler` contract nor the orchestrator's + * `typeof result === 'string'` Privacy-Shield gate changes. + */ +export interface McpInputRequiredSidecar extends McpSidecarIdentity { + readonly kind: 'input_required'; + /** The parked record — carries `serverName` so the card can attribute the + * request, which is a security requirement, not cosmetics. */ + readonly pending: PendingMcpInput; +} + +/** Union of everything the sidecar channel can carry. Add new members here; + * consumers switch on `kind`. */ +export type McpSidecarPayload = + | McpStructuredOutputSidecar + | McpInputRequiredSidecar; + +/** + * Out-of-band sink for payloads that must NOT reach the model as text. + * Implementations must be fast and MUST NOT throw; the manager additionally + * guards with try/catch so the sidecar can never break a tool call. Mirrors the + * `onToolCall` audit-observer contract. + */ +export type McpStructuredSink = (payload: McpSidecarPayload) => void; + export interface McpManagerOptions { readonly onToolCall?: McpCallObserver; readonly guard?: McpCallGuard; readonly auth?: McpAuthProvider; + /** Issue #547 (W1-3) — see `McpStructuredSink`. Optional: omitting it leaves + * behaviour byte-identical to before. */ + readonly structuredSink?: McpStructuredSink; + /** + * Issue #544 (W2-1) — where a `resultType: "input_required"` call gets parked + * until the user answers. Same optional-dependency shape as `auth` / + * `structuredSink`: omitting it leaves every existing path byte-identical, + * and an `input_required` result then degrades to a plain tool error + * (`mcpInputUnsupportedError`) rather than vanishing. + */ + readonly pendingInput?: PendingMcpInputStore; } /** True when an error/result string looks like an authorization failure. */ @@ -164,8 +376,15 @@ function looksUnauthorized(text: string): boolean { * (request timeout, dropped/closed connection, socket reset) — NOT an auth or * application-level tool error. */ function looksTransient(text: string): boolean { + // W0-5 — auth wins. `-32001` used to be matched as a bare numeric code here, + // which contradicted this function's own contract: the code is only + // *implementation-defined*, and servers legitimately use it for Unauthorized + // (omadia's own LoopbackMcpServer does, see its 401 branch). A genuine + // Unauthorized was therefore retried once — an extra doomed round trip that + // delayed the auth prompt. A real SDK request timeout still retries: it + // carries "Request timed out" and matches the timeout pattern below. + if (looksUnauthorized(text)) return false; return ( - /-?32001\b/.test(text) || /timed?\s*out|timeout/i.test(text) || /connection closed|connection reset|econnreset|socket hang ?up|network error|fetch failed|und_err/i.test( text, @@ -181,9 +400,103 @@ interface Pooled { const CLIENT_INFO = { name: 'omadia-agent-builder', version: '0.1.0' } as const; +/** + * W0-2 — explicit per-call MCP request policy. `callTool` used to pass no + * `RequestOptions` at all and silently inherited the SDK's 60s default, so the + * real ceiling was undocumented and un-tunable. Stated here instead: + * - `timeout`: idle budget for one request. + * - `resetTimeoutOnProgress`: a server streaming progress notifications keeps + * its budget alive (long Odoo/Confluence reports do exactly this)… + * - `maxTotalTimeout`: …but never past this absolute ceiling, so a chatty + * server cannot extend a call forever. + * Both are env-tunable per deployment. + * + * ── ORDERING INVARIANT (W3-A) ─────────────────────────────────────────────── + * These are the INNER bounds. The orchestrator's per-tool dispatch deadline + * (`OMADIA_TOOL_DISPATCH_TIMEOUT_MS`, see `DEFAULT_TOOL_DISPATCH_TIMEOUT_MS` in + * `orchestrator.ts`) is the OUTER bound and must stay strictly LOOSER than + * `maxTotalTimeout` here. It used to default to 120 s — i.e. INSIDE this 180 s + * ceiling — so an MCP-backed sub-agent legitimately streaming progress for its + * full allowance was killed by the outer bound first, and the model saw a + * generic dispatch-deadline error instead of the MCP layer's own diagnosis. + * `assertTimeoutHierarchy()` in `orchestrator.ts` refuses to boot on an + * incoherent configuration, and `resolveToolDispatchTimeoutMs()` clamps a + * runtime env change that would re-create the inversion. + * + * ── …AND THE RETRY MUST NOT DOUBLE IT ─────────────────────────────────────── + * `maxTotalTimeout` bounds ONE `callTool` request. `callTool` below makes up to + * {@link MCP_CALL_MAX_ATTEMPTS} of them, so the naive reading of the invariant — + * outer (240 s) > ceiling (180 s) — was asserted against a per-attempt number + * while reality was 2 × 180 s = 360 s: the inversion W3-A removed, re-created by + * a knob nobody counted. The fix is on this side, not by inflating the outer + * bound: the attempts SHARE one absolute budget, so `maxTotalTimeout` means what + * its name says. Attempt 2 gets only the remainder, and no retry is started once + * the budget is spent. {@link resolveMcpCallTimeouts} therefore reports a + * `worstCaseTotalMs` the hierarchy check can assert against the real ceiling. + */ +const DEFAULT_MCP_CALL_TIMEOUT_MS = 60_000; +const DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 180_000; + +/** + * Attempts one `callTool` may make: the original plus ONE transient retry. + * Exported so the timeout hierarchy reasons about the real number rather than + * re-deriving it from the loop below. + */ +export const MCP_CALL_MAX_ATTEMPTS = 2; + +/** + * Floor on the remaining budget worth spending on a retry. Below this a second + * attempt cannot plausibly finish, so it is skipped rather than started and + * immediately timed out — which would turn one honest ceiling error into a + * misleading "the retry also failed". + */ +const MCP_RETRY_MIN_REMAINING_MS = 1_000; + +function envMs(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; +} + +/** + * The MCP request policy as it would be applied to the NEXT `callTool` — the + * same resolution `callTool` performs, exposed so the timeout-hierarchy + * invariant can be asserted against the real numbers (including env overrides) + * rather than against a copy of the defaults. + */ +export function resolveMcpCallTimeouts(): { + readonly timeoutMs: number; + readonly maxTotalTimeoutMs: number; + /** + * Wall-clock worst case for one `callTool`, retries INCLUDED — the number the + * outer dispatch deadline actually has to be looser than. Equal to + * `maxTotalTimeoutMs` because the attempts share one budget; it is reported + * separately so the hierarchy check keeps asserting against the real worst + * case if that ever stops being true. + */ + readonly worstCaseTotalMs: number; +} { + const maxTotalTimeoutMs = envMs( + 'OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS', + DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS, + ); + return { + timeoutMs: envMs('OMADIA_MCP_CALL_TIMEOUT_MS', DEFAULT_MCP_CALL_TIMEOUT_MS), + maxTotalTimeoutMs, + worstCaseTotalMs: maxTotalTimeoutMs, + }; +} + export class McpManager { private readonly pool = new Map(); private readonly connecting = new Map>(); + /** Issue #547 (W1-3) — declared `outputSchema` per `${serverId} ${tool}`. + * `callTool` only receives a name, so the schema learned at discovery (or + * rehydrated from the persisted descriptor by the adapters below) is cached + * here and attached to the sidecar. A miss just omits the schema. */ + private readonly outputSchemas = new Map>(); /** Optional audit observer + dispatch guard (issues #462/#454). Existing * `new McpManager()` call sites keep working unchanged. */ @@ -196,9 +509,10 @@ export class McpManager { private emitCall( cfg: McpServerConfig, toolName: string, - ok: boolean, + outcome: McpCallOutcome, error: string | null, startedAt: number, + actingIdentity: string | null, ): void { if (!this.options?.onToolCall) return; try { @@ -218,18 +532,148 @@ export class McpManager { callerKind, callerAgent: ctx?.mcpCallerId ?? ctx?.agentSlug ?? null, turnId: inTurn ? ctx.turnId : null, - ok, + // W2-1: `fail` is the ONLY outcome that clears `ok`. A parked + // `input_required` call did not fail, so it must not show up in any + // failure-rate query built on `ok`. + ok: outcome !== 'fail', + outcome, // Bounded: external error strings can carry upstream data; the audit // table is append-only, so cap what gets persisted (codex W2 finding). error: error === null ? null : error.length > 300 ? `${error.slice(0, 300)}…` : error, durationMs: Date.now() - startedAt, calledAt: new Date(), + // W0-1: never left blank. An unattributable call is recorded AS + // unattributable rather than silently omitted. + actingIdentity: actingIdentity ?? ctx?.mcpUserKey ?? null, }); } catch { /* the audit trail must never break a tool call */ } } + /** + * Issue #547 (W1-3) — remember a tool's declared `outputSchema` so a later + * `callTool` (which only gets a name) can attach it to the sidecar. Called + * automatically by `listTools`, and by the adapter factories below so a + * descriptor rehydrated from the DB after a restart is just as good as a + * freshly discovered one. Idempotent; a schema-less descriptor is a no-op. + */ + rememberToolSchema(serverId: string, tool: McpToolDescriptor): void { + if (!tool.outputSchema) return; + this.outputSchemas.set(schemaKey(serverId, tool.name), tool.outputSchema); + } + + /** Emit one structured-result sidecar. Out-of-band by construction: the + * caller has already produced the model-facing string and ignores this. */ + private emitStructured( + cfg: McpServerConfig, + toolName: string, + structured: unknown, + ): void { + if (!this.options?.structuredSink) return; + try { + const ctx = turnContext.current(); + const outputSchema = this.outputSchemas.get(schemaKey(cfg.id, toolName)); + this.options.structuredSink({ + kind: 'structured_output', + serverId: cfg.id, + toolName, + turnId: ctx !== undefined && ctx.turnId !== '' ? ctx.turnId : null, + structured, + ...(outputSchema ? { outputSchema } : {}), + }); + } catch { + /* the sidecar must never break a tool call */ + } + } + + /** + * W2-1 (#544) — park a `resultType: "input_required"` call and hand the model + * a stable sentinel instead of a result. + * + * Every exit here is deliberate and distinguishable; nothing degrades into + * "looked like success": + * - no store wired → plain tool error (`mcpInputUnsupportedError`) + * - unusable `inputRequests` → plain tool error (`mcpInputMalformedError`) + * - bounce cap tripped → plain tool error (`mcpInputReplayCappedError`) + * - second park in one turn → `MCP_INPUT_ALREADY_PENDING_SENTINEL` + * - parked → `mcpInputRequiredSentinel` + * + * The three error exits audit as `'fail'` (they ARE failed calls — the tool + * produced nothing usable). The two park exits audit as `'input_required'`, + * which keeps `ok` true without claiming a result was delivered. + */ + private parkInputRequired( + cfg: McpServerConfig, + toolName: string, + args: Record, + res: unknown, + startedAt: number, + actingIdentity: string | null, + ): string { + const store = this.options?.pendingInput; + if (!store) { + const failure = mcpInputUnsupportedError(cfg.name, toolName); + this.emitCall(cfg, toolName, 'fail', failure, startedAt, actingIdentity); + return failure; + } + const parsed = parseMcpInputRequests( + (res as { inputRequests?: unknown }).inputRequests, + ); + if (!parsed.ok) { + const failure = mcpInputMalformedError(cfg.name, toolName, parsed.reason); + this.emitCall(cfg, toolName, 'fail', failure, startedAt, actingIdentity); + return failure; + } + const prompt = extractMcpInputPrompt(res); + const record: PendingMcpInput = { + correlationId: randomUUID(), + serverId: cfg.id, + serverName: cfg.name, + toolName, + originalArgs: args, + inputRequests: parsed.fields, + ...(prompt !== undefined ? { prompt } : {}), + // A call that already carries `inputResponses` IS the replay — the only + // signal available here, since the manager is stateless per call. + replayDepth: REPLAY_ARG_KEY in args ? MCP_INPUT_MAX_REPLAY_DEPTH : 0, + }; + // Parked WITHOUT an owner: the manager has no reliable turn identity (see + // `PendingMcpInputStore`). The orchestrator binds the owner when it claims + // the record via the correlation id embedded in the sentinel below. Until + // then the record is replayable by nobody. + if (store.put(record) === 'replay_capped') { + const failure = mcpInputReplayCappedError(record); + this.emitCall(cfg, toolName, 'fail', failure, startedAt, actingIdentity); + return failure; + } + this.emitCall(cfg, toolName, 'input_required', null, startedAt, actingIdentity); + this.emitInputRequired(cfg, toolName, record); + return mcpInputRequiredSentinel(record); + } + + /** Emit one `input_required` sidecar. Same never-throws contract as + * `emitStructured`; consumers switch on `kind`. */ + private emitInputRequired( + cfg: McpServerConfig, + toolName: string, + pending: PendingMcpInput, + ): void { + if (!this.options?.structuredSink) return; + try { + const ctx = turnContext.current(); + this.options.structuredSink({ + kind: 'input_required', + serverId: cfg.id, + toolName, + turnId: ctx !== undefined && ctx.turnId !== '' ? ctx.turnId : null, + pending, + }); + } catch { + /* the sidecar must never break a tool call */ + } + } + /** Discover the tool list a server exposes. Throws on connection failure so * the operator-facing `/discover` endpoint can report it. */ async listTools(cfg: McpServerConfig): Promise { @@ -248,13 +692,22 @@ export class McpManager { const { client } = await this.getOrConnect(await this.withResolvedConfig(cfg), token); const res = await client.listTools(); const tools = Array.isArray(res?.tools) ? res.tools : []; - return tools.map((t) => ({ + const descriptors = tools.map((t) => ({ name: String(t.name), ...(t.description ? { description: String(t.description) } : {}), ...(t.inputSchema ? { inputSchema: t.inputSchema as Record } : {}), + // Issue #547 (W1-3): carry the declared output schema through discovery + // so it can be persisted and later attached to the sidecar. Object-only — + // a server that sends a non-object here gets it dropped rather than + // poisoning the descriptor (arrays are objects in JS, so exclude them). + ...(isPlainObject(t.outputSchema) + ? { outputSchema: t.outputSchema as Record } + : {}), })); + for (const d of descriptors) this.rememberToolSchema(cfg.id, d); + return descriptors; } /** Invoke a tool. Never throws — returns an `Error: …` string on failure so @@ -265,13 +718,24 @@ export class McpManager { args: Record, ): Promise { const startedAt = Date.now(); + // Resolve the acting identity FIRST (W0-1), before the guard can short- + // circuit: a denied call still has to say whose authority it would have + // used. Only paid for when auditing is actually on. + let actingIdentity: string | null = null; + if (this.options?.onToolCall && this.options.auth?.resolveIdentity) { + try { + actingIdentity = await this.options.auth.resolveIdentity(cfg); + } catch { + /* identity resolution must not break the call path */ + } + } // Dispatch-time policy gate (issue #454): checked on EVERY call, so a // verdict that turned risky on re-discover blocks immediately and an // operator ack unblocks immediately — independent of registry rebuilds. try { const denial = this.options?.guard?.(cfg.id, toolName); if (denial) { - this.emitCall(cfg, toolName, false, denial, startedAt); + this.emitCall(cfg, toolName, 'fail', denial, startedAt, actingIdentity); return denial; } } catch { @@ -295,8 +759,37 @@ export class McpManager { // that intermittently returns "-32001 Request timed out" or drops the // connection). The retry drops the pooled connection first so it reconnects // fresh; auth-looking and real tool errors are NOT retried. + // + // #542 prerequisite — EXCEPT under an exactly-once idempotency scope. A + // transient failure is indistinguishable from "the server executed the write + // and the response was lost", so retrying a WRITE-capable call can duplicate + // a mutation (a second Odoo/M365 write = customer-data damage). When + // `ToolDispatchService` dispatched this call as write-capable with an + // idempotency key it publishes `exactlyOnce`, and this loop then makes ONE + // attempt: at-most-once beats at-least-once for writes. + // + // The mitigation itself is untouched for everything else — read tools, and + // write tools dispatched without an idempotency key, still get the retry. + // + // W4 — the retry does NOT get a fresh `maxTotalTimeout`. Two attempts each + // allowed the full absolute ceiling made one `callTool` worth up to + // 2 × 180 s = 360 s of wall clock, above the 240 s dispatch deadline that is + // supposed to be the LOOSER, outer bound — the exact inversion W3-A existed + // to remove, re-created by a retry nobody counted in the invariant. The + // attempts therefore share one budget: attempt 2 runs with what is left, and + // is not started at all once the budget is spent. + const idempotency = currentIdempotencyScope(); + const maxAttempts = + idempotency?.exactlyOnce === true ? 1 : MCP_CALL_MAX_ATTEMPTS; let lastFailure = `Error: MCP tool "${toolName}" on "${cfg.name}" failed.`; - for (let attempt = 1; attempt <= 2; attempt += 1) { + const budgetStartedAt = Date.now(); + /** What is left of the shared absolute ceiling, or `null` when it is gone. */ + const remainingBudgetMs = (): number | null => { + const policy = resolveMcpCallTimeouts(); + const left = policy.maxTotalTimeoutMs - (Date.now() - budgetStartedAt); + return left >= MCP_RETRY_MIN_REMAINING_MS ? left : null; + }; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { let pooled: Pooled; try { pooled = await this.getOrConnect(cfg, token); @@ -305,18 +798,33 @@ export class McpManager { // (streamable-HTTP surfaces the 401 as "-32000 Connection closed"), so // this path must also offer the auth prompt — not just tool-level errors. const failure = `Error: could not connect to MCP server "${cfg.name}": ${msg(err)}`; - if (attempt < 2 && looksTransient(failure) && token !== null) { + if ( + attempt < maxAttempts && + looksTransient(failure) && + token !== null && + remainingBudgetMs() !== null + ) { await this.close(this.poolKey(cfg, token)); lastFailure = failure; continue; } - return this.handleFailure(cfg, toolName, token, failure, startedAt); + return this.handleFailure(cfg, toolName, token, failure, startedAt, actingIdentity); } try { const res = await pooled.client.callTool( { name: toolName, arguments: args, + // #542 prerequisite — advertise the idempotency key so a server that + // implements dedupe can recognise a duplicate as the SAME call. + // Advisory only: MCP defines no standard idempotency field and no + // server is obliged to honour this, so it is never the protection — + // the `maxAttempts` clamp above and the dispatcher's dedupe store + // are. Rides `_meta`, the spec's extension channel, so a server that + // ignores it sees byte-identical arguments. + ...(idempotency !== undefined + ? { _meta: { idempotencyKey: idempotency.key } } + : {}), }, // Tolerate off-spec `structuredContent` (some third-party MCP servers — // e.g. the hosted Strava proxy — return it as a JSON array instead of an @@ -324,6 +832,24 @@ export class McpManager { // the failure surfaces to the model as "-32000 Connection closed", // making every tool call on that server look like a transport failure. LENIENT_CALL_TOOL_RESULT_SCHEMA, + // Stated request policy instead of the SDK's implicit 60s default — + // see DEFAULT_MCP_CALL_TIMEOUT_MS. `maxTotalTimeout` is the SHARED + // remainder, not a fresh allowance per attempt, so N attempts still + // cost at most one ceiling of wall clock (see the note above the loop). + (() => { + const policy = resolveMcpCallTimeouts(); + const elapsed = Date.now() - budgetStartedAt; + const remaining = Math.max( + MCP_RETRY_MIN_REMAINING_MS, + policy.maxTotalTimeoutMs - elapsed, + ); + return { + // The per-request idle budget can never outlive the absolute one. + timeout: Math.min(policy.timeoutMs, remaining), + resetTimeoutOnProgress: true, + maxTotalTimeout: remaining, + }; + })(), ); const rendered = renderToolResult(res); // MCP protocol errors resolve (isError result) instead of throwing — @@ -331,23 +857,55 @@ export class McpManager { const protocolError = res !== null && typeof res === 'object' && (res as { isError?: unknown }).isError === true; if (protocolError) { - return this.handleFailure(cfg, toolName, token, rendered, startedAt); + return this.handleFailure(cfg, toolName, token, rendered, startedAt, actingIdentity); + } + // ── W2-1 (#544) MRTR mid-call user input ───────────────────────────── + // Checked BEFORE the success audit and INSIDE the attempt loop with an + // unconditional `return`, which is what makes the two "must nots" true + // by construction: no retry attempt is consumed (we never `continue`), + // and no failure row is emitted (`handleFailure` is not on this path). + if (isInputRequiredResult(res)) { + return this.parkInputRequired( + cfg, + toolName, + args, + res, + startedAt, + actingIdentity, + ); + } + this.emitCall(cfg, toolName, 'ok', null, startedAt, actingIdentity); + // Issue #547 (W1-3) — hand any `structuredContent` to the out-of-band + // sink. `rendered` above is already final and is NOT re-derived from + // this: the model-facing string is byte-identical with or without a + // sink installed. Error results are skipped by `extractStructured`. + const structured = extractStructured(res); + if (structured !== undefined) { + this.emitStructured(cfg, toolName, structured); } - this.emitCall(cfg, toolName, true, null, startedAt); return rendered; } catch (err) { // Drop the connection so the next call reconnects (server may have died). await this.close(this.poolKey(cfg, token)); const failure = `Error: MCP tool "${toolName}" on "${cfg.name}" failed: ${msg(err)}`; - if (attempt < 2 && looksTransient(failure)) { + // No retry once the shared absolute budget is spent: a call that already + // consumed its whole ceiling is not "transiently flaky", and starting a + // second attempt with no time left would only replace an honest ceiling + // error with a confusing one — while doubling the wall clock the outer + // dispatch deadline was sized against. + if ( + attempt < maxAttempts && + looksTransient(failure) && + remainingBudgetMs() !== null + ) { lastFailure = failure; continue; } - return this.handleFailure(cfg, toolName, token, failure, startedAt); + return this.handleFailure(cfg, toolName, token, failure, startedAt, actingIdentity); } } // Both attempts hit a transient failure. - return this.handleFailure(cfg, toolName, token, lastFailure, startedAt); + return this.handleFailure(cfg, toolName, token, lastFailure, startedAt, actingIdentity); } /** @@ -363,6 +921,7 @@ export class McpManager { token: string | null, rawFailure: string, startedAt: number, + actingIdentity: string | null, ): Promise { const maybeAuth = token === null || looksUnauthorized(rawFailure); if (maybeAuth && this.options?.auth) { @@ -376,11 +935,11 @@ export class McpManager { /* fall back to the raw failure */ } if (authMessage) { - this.emitCall(cfg, toolName, false, 'auth_required', startedAt); + this.emitCall(cfg, toolName, 'fail', 'auth_required', startedAt, actingIdentity); return authMessage; } } - this.emitCall(cfg, toolName, false, rawFailure, startedAt); + this.emitCall(cfg, toolName, 'fail', rawFailure, startedAt, actingIdentity); return rawFailure; } @@ -530,6 +1089,16 @@ function inputSchemaOrEmpty(tool: McpToolDescriptor): { return { type: 'object', properties: {}, required: [] }; } +/** + * The `domain` an MCP server's tools are attributed to. Single derivation: + * `mcpToolToNativeSpec` stamps it onto every hydrated DomainTool, and the + * orchestrator's MCP input-replay privacy guard reuses it so a replay's + * bypass-receipt entry carries the SAME plugin id an ordinary dispatch would. + */ +export function mcpDomainForServer(serverName: string): string { + return `mcp.${slugifyDomain(serverName)}`; +} + /** Adapt an MCP tool into a top-level orchestrator NativeToolSpec. */ export function mcpToolToNativeSpec( serverName: string, @@ -540,7 +1109,7 @@ export function mcpToolToNativeSpec( description: tool.description ?? `MCP tool "${tool.name}" from server "${serverName}".`, input_schema: inputSchemaOrEmpty(tool), - domain: `mcp.${slugifyDomain(serverName)}`, + domain: mcpDomainForServer(serverName), }; } @@ -565,6 +1134,10 @@ export function mcpToolToLocalSubAgentTool( cfg: McpServerConfig, tool: McpToolDescriptor, ): LocalSubAgentTool { + // Issue #547 (W1-3): seed the schema cache from the (possibly DB-rehydrated) + // descriptor so the sidecar carries an outputSchema even when this process + // never ran discovery for this server. + manager.rememberToolSchema(cfg.id, tool); return { spec: { name: mcpNativeToolName(cfg.name, tool.name), @@ -609,6 +1182,68 @@ export function renderToolResult(res: any): string { return JSON.stringify(res ?? {}); } +/** + * Issue #547 (W1-3) — pull an MCP result's `structuredContent` out for the + * out-of-band sidecar. Deliberately a SEPARATE function from + * `renderToolResult`, which stays byte-for-byte unchanged: the model-facing + * string must not shift because a sink is installed. + * + * Returns the payload exactly as the server sent it (object, or array for + * off-spec hosted servers — see `LENIENT_CALL_TOOL_RESULT_SCHEMA`), never a + * re-parse of the rendered string. + * + * Returns `undefined` for: + * - a non-object / protocol-error result (nothing trustworthy to read), + * - `isError: true` (a failed call has no result to render structurally), + * - an absent `structuredContent`, + * - an explicit `null` — off-spec (the spec requires an object) and carries + * nothing to render, so it is folded into "absent" rather than emitting an + * empty sidecar. Keeps the sink contract simple: a payload arrives only + * when there is genuinely something in it. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function extractStructured(res: any): unknown | undefined { + if (res === null || typeof res !== 'object') return undefined; + if (res.isError === true) return undefined; + const structured = res.structuredContent; + if (structured === undefined || structured === null) return undefined; + return structured; +} + +/** Cache key for a per-server tool schema — same shape as the verdict maps in + * `agentBuilder.ts`. `serverId` is a UUID and so contains no space, which makes + * the FIRST space the unambiguous separator no matter what the tool name + * contains; no collision is possible. */ +function schemaKey(serverId: string, toolName: string): string { + return `${serverId} ${toolName}`; +} + +/** True for a non-null, non-array object. */ +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * The argument key the replay adds. Also the manager's only signal that a call + * IS a replay — see `parkInputRequired`. Exported so the replayer and the tests + * cannot drift from it. + */ +export const REPLAY_ARG_KEY = 'inputResponses'; + +/** + * W2-1 (#544) — does this result ask for mid-call user input? + * + * Requires `resultType === 'input_required'` exactly. An `isError` result is + * excluded: a failed call has no pending continuation to park, and treating one + * as a card would turn every server-side error into a prompt for the user. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isInputRequiredResult(res: any): boolean { + if (res === null || typeof res !== 'object') return false; + if (res.isError === true) return false; + return res.resultType === MCP_RESULT_TYPE_INPUT_REQUIRED; +} + /** Split a shell command line into argv. Honours simple double/single quotes; * not a full shell parser, but enough for `npx -y @scope/pkg --flag "v"`. */ export function splitCommand(line: string): string[] { diff --git a/middleware/packages/harness-orchestrator/src/mcp/pendingMcpInput.ts b/middleware/packages/harness-orchestrator/src/mcp/pendingMcpInput.ts new file mode 100644 index 00000000..182b7e0a --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/mcp/pendingMcpInput.ts @@ -0,0 +1,650 @@ +/** + * Mid-call user input for MCP tools — MRTR `resultType: "input_required"` + * (issue #544, W2-1). + * + * ## What MRTR asks for, and what we actually do + * + * The MCP "mid-request tool result" shape lets a server answer a `tools/call` + * with `resultType: "input_required"` plus a list of `inputRequests`, meaning + * "I cannot finish without more information from the human". The spec imagines + * the client collecting that input and **retrying the original request**, with + * the server-side call still logically in flight. + * + * omadia cannot do that today, and this module is deliberately explicit about + * it. There is no per-turn suspend/resume store anywhere in the orchestrator: + * `turnContext` is an `AsyncLocalStorage` whose lifetime *is* the turn, and + * parking a turn mid-tool-loop would hold an HTTP/Teams connection open past + * every proxy and channel idle timeout. So W2-1 rides the pattern that already + * exists for `ask_user_choice`: the turn **ends**, the channel renders a card, + * and the user's answer arrives as a *fresh turn* which replays the call. + * + * ### Accepted cost (say this out loud in review) + * + * The replay is a NEW `tools/call` in a LATER turn, against a possibly + * reconnected transport — the pooled client may have been dropped and rebuilt + * in between. For a stateless HTTP server that is indistinguishable from the + * retry MRTR describes. For a **stdio server holding process state** tied to + * the original in-flight call, it is not: that state may be gone, and the + * server sees a fresh call rather than a continuation. Servers that need true + * continuation semantics are out of scope until omadia has a real turn + * suspend/resume store. + * + * ## Security: the store key is not a detail + * + * A parked record is replayed with whatever free text the human typed, back + * into a named MCP server. Keying it on `sessionScope` alone would be a + * cross-user hole: `resolveScope` returns the literal `'http-default'` for + * unscoped HTTP turns, so every such caller would share one key (issue #445). + * The key is therefore the triple `{userId, sessionId, correlationId}`, and a + * lookup that differs in ANY component misses. See {@link serializeKey}. + */ + +/** How long a parked record stays replayable. Hard ceiling — a record past it + * is unreachable even if the user eventually answers the card. */ +export const PENDING_MCP_INPUT_TTL_MS = 15 * 60_000; + +/** Hard cap on parked records, so a server that spams `input_required` cannot + * grow the map without bound. Oldest-first eviction. */ +export const PENDING_MCP_INPUT_MAX_ENTRIES = 500; + +/** Maximum number of fields a single card will render. A server asking for + * more is treated as malformed rather than rendered partially. */ +export const MCP_INPUT_REQUEST_MAX_FIELDS = 8; + +/** + * How many times one logical call may bounce through the card. + * + * `0` is the original call, `1` is the replay. A replay that comes back + * `input_required` AGAIN is refused: the pair (model, server) would otherwise + * be able to ping-pong the user indefinitely, one turn per round, with the + * user paying for every turn. + */ +export const MCP_INPUT_MAX_REPLAY_DEPTH = 1; + +const NAME_MAX = 64; +const LABEL_MAX = 120; +const DESCRIPTION_MAX = 500; +const PROMPT_MAX = 500; +const RESPONSE_VALUE_MAX = 4_000; + +/** One field the server wants filled in. Free text by construction — this is + * NOT the 2-4 button shape `ask_user_choice` uses, which is exactly why the + * channel payload is a sibling of `pendingUserChoice` and not a reuse. */ +export interface McpInputField { + readonly name: string; + readonly label?: string; + readonly description?: string; + /** Render masked. Advisory: the value still crosses the wire to the server. */ + readonly secret?: boolean; + readonly required?: boolean; +} + +/** A tool call parked mid-flight, waiting for the human to fill in fields. */ +export interface PendingMcpInput { + readonly correlationId: string; + readonly serverId: string; + /** + * Operator-configured display name of the asking server. + * + * MANDATORY on the card, not decoration. An MCP server can now make omadia + * render an arbitrary free-text prompt; without attribution a hostile server + * could phish credentials through a card the user reads as omadia's own UI. + * The card names who is asking. + */ + readonly serverName: string; + readonly toolName: string; + /** The arguments of the ORIGINAL call, replayed verbatim alongside the + * collected `inputResponses`. */ + readonly originalArgs: Record; + readonly inputRequests: readonly McpInputField[]; + /** Server-supplied prose shown above the fields, when it sent any. */ + readonly prompt?: string; + /** See {@link MCP_INPUT_MAX_REPLAY_DEPTH}. */ + readonly replayDepth: number; +} + +/** Who a CLAIMED record belongs to. Both components participate in the key. */ +export interface PendingMcpInputOwner { + readonly userId: string | null; + readonly sessionId: string | null; +} + +/** The only valid replay lookup key. All three components participate. */ +export interface PendingMcpInputKey extends PendingMcpInputOwner { + readonly correlationId: string; +} + +/** Outcome of a {@link PendingMcpInputStore.put}. `callTool` maps each to a + * different model-facing string, so neither is silently indistinguishable from + * success. */ +export type PutPendingMcpInputResult = 'stored' | 'replay_capped'; + +/** + * Two-phase by necessity: PARK is performed by the `McpManager`, CLAIM by the + * orchestrator. + * + * ## Why the owner is bound at claim time, not at park time + * + * `McpManager.callTool` is reached through the published + * `NativeToolHandler = (input: unknown) => Promise` contract, so it has + * no turn parameter. + * + * HISTORICAL NOTE (W3-A): it could not read the turn identity from ambient + * context either, because the streaming path established `turnContext` with + * `AsyncLocalStorage.enterWith` inside an async generator — a binding that does + * not survive the generator's first suspension, leaving + * `turnContext.current()` empty inside every tool handler on a `chatStream` + * turn. That defect is FIXED: the entry point now uses + * `turnContext.runGenerator`, and `test/orchestrator/turnContextPropagation.test.ts` + * pins the context as populated on both paths. + * + * The two-phase design is deliberately KEPT anyway. Claim-time binding does not + * depend on ambient context being correct at park time, so it stays robust + * against a future dispatch surface that legitimately has no turn scope (the + * standalone `ToolDispatchService`, a public endpoint). Reading the owner from + * ambient context would be a regression in robustness, not a simplification. + * + * So the manager parks with no owner and returns a sentinel that CARRIES the + * correlation id. The orchestrator — which does hold the turn's `input` + * reliably on both paths — reads that sentinel out of the batch's tool results + * and claims the record, binding it to `{userId, sessionId}` at that moment. + * The linkage is the tool result itself, exactly the mechanism + * `extractToolEmittedChoice` already uses for plugin-emitted choice cards. + * + * The security property is unchanged: a record can only ever be REPLAYED via + * `take()` with the full triple, and it only acquires an owner from the turn + * that actually made the call. An unclaimed record is replayable by nobody. + */ +export interface PendingMcpInputStore { + /** Park a record. Unclaimed and ownerless until the orchestrator claims it. */ + put(record: PendingMcpInput): PutPendingMcpInputResult; + /** + * Bind a parked record to `owner` and return it for rendering. Idempotent: + * a second claim of the same correlation id misses, which is what makes the + * first sentinel in a batch the winner. + * + * The keyed record deliberately SURVIVES this call — the replay happens in a + * later turn and must still be able to `take()` it. + */ + claim( + correlationId: string, + owner: PendingMcpInputOwner, + ): PendingMcpInput | undefined; + /** Discard a parked record outright (a losing sibling in the same batch). */ + drop(correlationId: string): void; + /** Single-use consume for replay. A second `take` of the same key misses. */ + take(key: PendingMcpInputKey): PendingMcpInput | undefined; + /** Test/ops introspection. Never used for control flow. */ + size(): number; +} + +interface Entry { + readonly record: PendingMcpInput; + readonly expiresAt: number; + /** `undefined` until the orchestrator claims it. An unclaimed record cannot + * be replayed by anyone, because `take` needs a matching owner. */ + owner?: PendingMcpInputOwner; +} + +/** + * Unambiguous key serialization. `JSON.stringify` of a fixed-arity tuple, so no + * separator can be forged from inside a component: a `userId` containing the + * delimiter cannot make itself look like a different `{userId, sessionId}` pair + * the way a naive `${a}:${b}:${c}` join would allow. + * + * `null` and the string `'null'` also stay distinct, which matters because an + * unauthenticated turn legitimately has `userId === null`. + */ +function serializeOwner(owner: PendingMcpInputOwner): string { + return JSON.stringify([owner.userId, owner.sessionId]); +} + +export interface InMemoryPendingMcpInputStoreOptions { + readonly ttlMs?: number; + readonly maxEntries?: number; + /** Injectable clock — TTL expiry is tested by advancing this, not by sleeping. */ + readonly now?: () => number; +} + +/** + * Process-local store. Single-process by design: a parked record is only + * meaningful to the instance that will serve the user's next turn, and omadia + * runs one middleware process per machine with sticky sessions. A multi-node + * deployment would need this behind the session store — noted, not built. + */ +export class InMemoryPendingMcpInputStore implements PendingMcpInputStore { + /** correlationId → entry. The id is a random UUID, so it is unguessable; the + * owner bound at claim time is what makes a LEAKED id unusable elsewhere. */ + private readonly entries = new Map(); + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + + constructor(options?: InMemoryPendingMcpInputStoreOptions) { + this.ttlMs = options?.ttlMs ?? PENDING_MCP_INPUT_TTL_MS; + this.maxEntries = options?.maxEntries ?? PENDING_MCP_INPUT_MAX_ENTRIES; + this.now = options?.now ?? Date.now; + } + + put(record: PendingMcpInput): PutPendingMcpInputResult { + // `replayDepth: n` means "answering this card produces replay n+1". With a + // max of 1 that permits the original call's card (0) and refuses a card + // raised BY the replay (1) — the ping-pong cap. + if (record.replayDepth >= MCP_INPUT_MAX_REPLAY_DEPTH) return 'replay_capped'; + this.sweep(); + this.entries.set(record.correlationId, { + record, + expiresAt: this.now() + this.ttlMs, + }); + this.evictOverflow(); + return 'stored'; + } + + claim( + correlationId: string, + owner: PendingMcpInputOwner, + ): PendingMcpInput | undefined { + const entry = this.entries.get(correlationId); + if (entry === undefined) return undefined; + if (entry.expiresAt <= this.now()) { + this.entries.delete(correlationId); + return undefined; + } + // Already claimed → miss. This is what makes the FIRST sentinel in a batch + // the winner, and what stops a replayed sentinel from re-carding. + // + // Trust-on-first-claim, deliberately. A review pass asked why this does not + // also compare the record's own `userId`/`sessionId` the way `take()` does: + // because a parked record HAS none. `PendingMcpInput` carries no owner + // fields at all — binding one here IS how it acquires them, which is the + // whole point of the two-phase design (see the interface doc: `callTool` is + // reached through a published handler contract with no turn parameter, and + // reading turn identity from ambient context at park time is exactly the + // robustness regression that design avoids). There is nothing to compare + // against, so the protection has to come from elsewhere, and it does: the + // correlation id is a random UUID that is only ever learned from the tool + // result of the call that parked it, inside the same turn's batch, so no + // other turn can present it; and the security property that actually + // matters — REPLAY — is enforced by `take()` on the full triple. + if (entry.owner !== undefined) return undefined; + entry.owner = owner; + // NOTE: the record is intentionally NOT removed. Claiming renders the card; + // `take()` in a later turn consumes it. + return entry.record; + } + + drop(correlationId: string): void { + this.entries.delete(correlationId); + } + + take(key: PendingMcpInputKey): PendingMcpInput | undefined { + const entry = this.entries.get(key.correlationId); + if (entry === undefined) return undefined; + // An UNCLAIMED record has no owner and is therefore replayable by nobody — + // do not consume it here, or a guessed id could burn someone else's card. + if (entry.owner === undefined) return undefined; + // The full triple. A mismatch is a miss and must NOT consume the record: + // otherwise a wrong-owner attempt would destroy the rightful owner's card. + if (serializeOwner(entry.owner) !== serializeOwner(key)) return undefined; + this.entries.delete(key.correlationId); + if (entry.expiresAt <= this.now()) return undefined; + return entry.record; + } + + size(): number { + this.sweep(); + return this.entries.size; + } + + private sweep(): void { + const now = this.now(); + for (const [k, entry] of this.entries) { + if (entry.expiresAt <= now) this.entries.delete(k); + } + } + + /** Map insertion order is oldest-first, so the first keys are the stalest. */ + private evictOverflow(): void { + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next(); + if (oldest.done === true) return; + this.entries.delete(oldest.value); + } + } +} + +// ── process-shared wiring ─────────────────────────────────────────────────── +// +// The `McpManager` (constructed in the kernel's `index.ts`) WRITES parked +// records; the Orchestrator (built from `OrchestratorDeps` in this package's +// `plugin.ts`) READS them. Neither can reach the other's construction site, so +// the single instance lives here — the same module-singleton shape +// `mcpGrantPolicy.ts` already uses for the dispatch guard. +// +// Not a hidden global with surprise lifetime: the store is process-local by +// design (see `InMemoryPendingMcpInputStore`), so "one per process" is exactly +// the correct scope, and both readers go through these two accessors. + +let sharedStore: PendingMcpInputStore | undefined; +let sharedReplayer: McpInputReplayer | undefined; + +/** The process-wide store. Created on first access so both sides get the same + * instance regardless of which one runs first. */ +export function sharedPendingMcpInputStore(): PendingMcpInputStore { + sharedStore ??= new InMemoryPendingMcpInputStore(); + return sharedStore; +} + +/** + * Register the replayer. Called by the kernel once the `McpManager` and the + * server registry exist — those are the only things that can perform a replay. + */ +export function setSharedMcpInputReplayer(replayer: McpInputReplayer): void { + sharedReplayer = replayer; +} + +/** The registered replayer, or `undefined` when the kernel wired none — in + * which case the orchestrator leaves the whole MRTR path inert. */ +export function sharedMcpInputReplayer(): McpInputReplayer | undefined { + return sharedReplayer; +} + +/** Test seam: drop both, so a suite cannot leak state into the next one. */ +export function resetSharedMcpInputWiring(): void { + sharedStore = undefined; + sharedReplayer = undefined; +} + +// ── parsing a server's `inputRequests` ────────────────────────────────────── + +/** Why a server's `input_required` result could not be turned into a card. */ +export type McpInputParseFailure = + | 'not_an_array' + | 'empty' + | 'too_many_fields' + | 'field_without_name' + | 'duplicate_field_name'; + +export type McpInputParseOutcome = + | { readonly ok: true; readonly fields: readonly McpInputField[] } + | { readonly ok: false; readonly reason: McpInputParseFailure }; + +function clamp(value: unknown, max: number): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + return trimmed.length > max ? trimmed.slice(0, max) : trimmed; +} + +/** + * Validate a server's `inputRequests` into renderable fields. + * + * Deliberately lenient about EXTRA keys (the MRTR shape is still settling and + * SDK 1.29.0 does not model it) and strict about the two things the card + * genuinely cannot work without: it must be a non-empty array, and every entry + * must carry a usable field name. A failure is reported, never papered over — + * `callTool` turns it into a plain error string so a malformed server degrades + * to an ordinary tool error instead of a broken card. + * + * `name` accepts `name`, `id`, or `key`; `label` accepts `label` or `title`. + * Servers in the wild use all of these and none of them is normative yet. + */ +export function parseMcpInputRequests(raw: unknown): McpInputParseOutcome { + if (!Array.isArray(raw)) return { ok: false, reason: 'not_an_array' }; + if (raw.length === 0) return { ok: false, reason: 'empty' }; + if (raw.length > MCP_INPUT_REQUEST_MAX_FIELDS) { + return { ok: false, reason: 'too_many_fields' }; + } + const fields: McpInputField[] = []; + const seen = new Set(); + for (const item of raw) { + if (item === null || typeof item !== 'object' || Array.isArray(item)) { + return { ok: false, reason: 'field_without_name' }; + } + const shape = item as Record; + const name = + clamp(shape['name'], NAME_MAX) ?? + clamp(shape['id'], NAME_MAX) ?? + clamp(shape['key'], NAME_MAX); + if (name === undefined) return { ok: false, reason: 'field_without_name' }; + if (seen.has(name)) return { ok: false, reason: 'duplicate_field_name' }; + seen.add(name); + const label = clamp(shape['label'], LABEL_MAX) ?? clamp(shape['title'], LABEL_MAX); + const description = clamp(shape['description'], DESCRIPTION_MAX); + fields.push({ + name, + ...(label !== undefined ? { label } : {}), + ...(description !== undefined ? { description } : {}), + ...(shape['secret'] === true || shape['sensitive'] === true + ? { secret: true } + : {}), + // Absent `required` means required: a server that bothered to block on a + // field is asking for it. Only an explicit `false` makes it optional. + ...(shape['required'] === false ? {} : { required: true }), + }); + } + return { ok: true, fields }; +} + +/** Pull the server's prose prompt out of an `input_required` result, if any. */ +export function extractMcpInputPrompt(res: unknown): string | undefined { + if (res === null || typeof res !== 'object') return undefined; + const shape = res as Record; + return ( + clamp(shape['message'], PROMPT_MAX) ?? + clamp(shape['prompt'], PROMPT_MAX) ?? + undefined + ); +} + +// ── model-facing sentinels ────────────────────────────────────────────────── + +/** + * Marker the model sees in place of a result when a call parks. + * + * It lands in the message log, so the model can and will read it — hence the + * explicit instruction not to re-call. Combined with the store's + * first-call-wins guard, a model that re-calls anyway gets + * {@link MCP_INPUT_ALREADY_PENDING_SENTINEL} rather than a second card. + */ +export const MCP_INPUT_REQUIRED_SENTINEL_PREFIX = '[mcp_input_required:'; + +/** + * The sentinel embeds the correlation id, which is what lets the orchestrator + * link a parked record to THIS turn without any ambient context — see + * {@link PendingMcpInputStore}. Same idea as the `_pendingUserChoice` payload + * plugins emit in their tool-result strings. + */ +export function mcpInputRequiredSentinel(record: PendingMcpInput): string { + const fieldNames = record.inputRequests.map((f) => f.name).join(', '); + return ( + `${MCP_INPUT_REQUIRED_SENTINEL_PREFIX}${record.correlationId}] ` + + `Der MCP-Server "${record.serverName}" braucht für "${record.toolName}" noch ` + + `Eingaben vom User (${fieldNames}). Der Turn endet hier: der User bekommt ein ` + + 'Eingabe-Formular und die Antwort wird im nächsten Turn automatisch an den ' + + 'Server übermittelt. Ruf das Tool NICHT erneut auf und erfinde keine Werte.' + ); +} + +/** + * Pull the correlation id back out of a tool-result string. + * + * Deliberately anchored at the START of the string: the sentinel is the WHOLE + * result the manager returned, so a server that merely echoes the prefix inside + * its own output text cannot forge a card. Returns `undefined` for anything + * else, so an ordinary tool result stays an ordinary tool result. + */ +export function parseMcpInputSentinel(result: string): string | undefined { + if (!result.startsWith(MCP_INPUT_REQUIRED_SENTINEL_PREFIX)) return undefined; + const end = result.indexOf(']', MCP_INPUT_REQUIRED_SENTINEL_PREFIX.length); + if (end === -1) return undefined; + const id = result.slice(MCP_INPUT_REQUIRED_SENTINEL_PREFIX.length, end).trim(); + return id.length > 0 && id.length <= NAME_MAX ? id : undefined; +} + +/** The bounce cap tripped — tell the model plainly, do not park again. */ +export function mcpInputReplayCappedError(record: PendingMcpInput): string { + return ( + `Error: MCP tool "${record.toolName}" on "${record.serverName}" asked for ` + + 'user input again after it had already been answered once. Aborted to avoid ' + + 'an endless input loop — report this to the user instead of retrying.' + ); +} + +/** Malformed `inputRequests` — an ordinary tool error, not a card. */ +export function mcpInputMalformedError( + serverName: string, + toolName: string, + reason: McpInputParseFailure, +): string { + return ( + `Error: MCP tool "${toolName}" on "${serverName}" returned ` + + `resultType "input_required" with unusable inputRequests (${reason}). ` + + 'Treat this as a failed tool call.' + ); +} + +/** No store wired — the deployment cannot park input at all. Deterministic + * degradation instead of a silently swallowed result. */ +export function mcpInputUnsupportedError( + serverName: string, + toolName: string, +): string { + return ( + `Error: MCP tool "${toolName}" on "${serverName}" requires mid-call user ` + + 'input, which is not enabled on this deployment. Treat this as a failed ' + + 'tool call.' + ); +} + +// ── reply envelope (card answer → next turn) ──────────────────────────────── + +/** + * Prefix of the synthetic user message the card submits. + * + * The card cannot smuggle a side channel to the orchestrator: a click on + * `ask_user_choice` simply fires a fresh user turn, and this rides the same + * road. The envelope is machine-readable so the orchestrator can resolve the + * correlation id and drive a FORCED tool call itself, rather than hoping the + * model chooses to re-call the tool with the right arguments. + * + * The orchestrator strips the envelope before anything is persisted or shown, + * so it never appears in the session log or the UI. + */ +export const MCP_INPUT_REPLY_PREFIX = '__mcp_input_reply__'; + +export interface McpInputReply { + readonly correlationId: string; + readonly inputResponses: Record; +} + +export function formatMcpInputReply(reply: McpInputReply): string { + return `${MCP_INPUT_REPLY_PREFIX} ${JSON.stringify(reply)}`; +} + +/** + * Parse the envelope out of a user message. Returns `undefined` for any + * ordinary message — including one that merely starts with the prefix but + * carries no valid payload, so a user who types the literal prefix gets a + * normal turn rather than an error. + */ +export function parseMcpInputReply( + userMessage: string, +): McpInputReply | undefined { + const trimmed = userMessage.trim(); + if (!trimmed.startsWith(MCP_INPUT_REPLY_PREFIX)) return undefined; + const payload = trimmed.slice(MCP_INPUT_REPLY_PREFIX.length).trim(); + if (payload.length === 0) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return undefined; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + const shape = parsed as Record; + const correlationId = clamp(shape['correlationId'], NAME_MAX); + if (correlationId === undefined) return undefined; + const rawResponses = shape['inputResponses']; + if ( + rawResponses === null || + typeof rawResponses !== 'object' || + Array.isArray(rawResponses) + ) { + return undefined; + } + const inputResponses: Record = {}; + for (const [k, v] of Object.entries(rawResponses as Record)) { + if (typeof v !== 'string') continue; + inputResponses[k.slice(0, NAME_MAX)] = v.slice(0, RESPONSE_VALUE_MAX); + } + return { correlationId, inputResponses }; +} + +/** + * W2-1 (#544) — claim the card for THIS turn out of the batch's tool results. + * + * Scans in submission order and claims the FIRST sentinel; every later one in + * the same batch is dropped, so a model that fired several MCP calls gets + * exactly one card and no orphaned records linger. Deterministic with the + * dispatch order, matching `extractToolEmittedChoice`'s documented rule. + * + * `results` carries the model-facing strings the orchestrator already holds — no + * ambient context is consulted, which is the whole point (see + * {@link PendingMcpInputStore}). + */ +export function claimMcpInputFromResults( + store: PendingMcpInputStore, + results: readonly string[], + owner: PendingMcpInputOwner, +): PendingMcpInput | undefined { + let claimed: PendingMcpInput | undefined; + for (const result of results) { + const correlationId = parseMcpInputSentinel(result); + if (correlationId === undefined) continue; + if (claimed === undefined) { + claimed = store.claim(correlationId, owner); + if (claimed !== undefined) continue; + } + // Either a later sibling, or a sentinel whose record is already claimed / + // expired. Nothing will ever render it, so do not leave it parked. + store.drop(correlationId); + } + return claimed; +} + +/** + * Human-readable stand-in for the envelope, used as the turn's `userMessage` + * everywhere the raw envelope would otherwise be persisted or displayed + * (session log, memory, chat transcript, privacy receipt). + * + * Field NAMES only. The values are what the user typed for a third-party MCP + * server and may be secrets (the card renders `secret` fields masked), so they + * must not land in a log — and the orchestrator does not need them again: the + * replay already happened. + */ +export function mcpInputReplyLabel(reply: McpInputReply): string { + const names = Object.keys(reply.inputResponses); + return names.length > 0 + ? `[Eingaben übermittelt: ${names.join(', ')}]` + : '[Eingaben übermittelt]'; +} + +/** + * Executes the replay. Implemented outside the orchestrator (which holds no + * `McpManager`) and injected, the same way the sticky Direct Line store is — + * see `OrchestratorDeps`. + */ +export interface McpInputReplayer { + /** + * Re-call `record.toolName` with `{...record.originalArgs, inputResponses}`. + * Returns the model-facing result string, or `undefined` when the server is + * no longer registered (the record is then dropped). + */ + replay( + record: PendingMcpInput, + inputResponses: Record, + ): Promise; +} diff --git a/middleware/packages/harness-orchestrator/src/nativeToolRegistry.ts b/middleware/packages/harness-orchestrator/src/nativeToolRegistry.ts index fe9f038c..86d638ce 100644 --- a/middleware/packages/harness-orchestrator/src/nativeToolRegistry.ts +++ b/middleware/packages/harness-orchestrator/src/nativeToolRegistry.ts @@ -27,6 +27,7 @@ import type { NativeToolAttachmentSink, NativeToolHandler, NativeToolSpec, + WriteCapability, } from '@omadia/plugin-api'; export interface NativeToolRegistration { @@ -68,6 +69,23 @@ export interface NativeToolRegistration { * without restart. Absent for marker-only kernel registrations. */ readonly readConfig?: (key: string) => unknown | undefined; + /** + * #542 prerequisite — the tool's declared write capabilities, i.e. the + * assertion "dispatching me may MUTATE data". + * + * This is the carrier the `WriteCapability` contract was always meant to land + * on. `plugin-api`'s `pluginContext.ts` notes that the annotation deliberately + * does NOT go on `NativeToolSpec`, because the whole spec is forwarded verbatim + * into the Anthropic tools list and unknown fields are rejected there — it + * belongs on "a non-model-facing carrier (manifest annotation / registration + * metadata)". This registration IS that carrier. + * + * Read by `ToolDispatchService` to decide whether a dispatch needs at-most-once + * protection. Absent or empty ⇒ treated as read-only: no idempotency dedupe, + * and the MCP transient-retry mitigation stays fully in force. A plugin that + * mutates data MUST declare this or it forfeits duplicate-write protection. + */ + readonly writeCapabilities?: readonly WriteCapability[]; } export interface NativeToolRegistrationOptions { @@ -84,6 +102,8 @@ export interface NativeToolRegistrationOptions { /** Slice 2.5 — see `NativeToolRegistration.readConfig`. Set by * `ToolsAccessor.register` as `(k) => config.get(k)`. */ readConfig?: (key: string) => unknown | undefined; + /** #542 — see `NativeToolRegistration.writeCapabilities`. */ + writeCapabilities?: readonly WriteCapability[]; } /** @@ -104,6 +124,10 @@ export interface NativeToolHandlerRegistrationOptions { * such an entry stays always-available, matching `register()`'s * kernel-internal (marker-only) convention. */ agentId?: string; + /** #542 — see `NativeToolRegistration.writeCapabilities`. Honoured on this + * path too: a handler-only registration is dispatchable by name, so it needs + * the same duplicate-write protection as a `register()`-contributed tool. */ + writeCapabilities?: readonly WriteCapability[]; } export class NativeToolRegistry { @@ -143,6 +167,9 @@ export class NativeToolRegistry { ...(options.readConfig !== undefined ? { readConfig: options.readConfig } : {}), + ...(options.writeCapabilities !== undefined + ? { writeCapabilities: options.writeCapabilities } + : {}), } : { name }; this.entries.set(name, entry); @@ -182,6 +209,9 @@ export class NativeToolRegistry { ? { attachmentSink: options.attachmentSink } : {}), ...(options.agentId !== undefined ? { agentId: options.agentId } : {}), + ...(options.writeCapabilities !== undefined + ? { writeCapabilities: options.writeCapabilities } + : {}), }; this.entries.set(name, entry); return () => { diff --git a/middleware/packages/harness-orchestrator/src/orchestrator.ts b/middleware/packages/harness-orchestrator/src/orchestrator.ts index 8f0691f2..1cadf613 100644 --- a/middleware/packages/harness-orchestrator/src/orchestrator.ts +++ b/middleware/packages/harness-orchestrator/src/orchestrator.ts @@ -1,5 +1,9 @@ import { randomUUID } from 'node:crypto'; import type { Pool } from 'pg'; +import { + mcpDomainForServer, + resolveMcpCallTimeouts, +} from './mcp/mcpClient.js'; import { deriveAgentsConsulted, toSemanticAnswer, @@ -10,6 +14,7 @@ import { type DirectLineSessionState, type DiagramAttachment, type OutgoingFileAttachment, + type PendingMcpInputCard, type PendingRoutineList, type SemanticAnswer, } from '@omadia/channel-sdk'; @@ -47,6 +52,17 @@ import type { AskUserChoiceTool, PendingUserChoice, } from './tools/askUserChoiceTool.js'; +import type { + McpInputReplayer, + McpInputReply, + PendingMcpInput, + PendingMcpInputStore, +} from './mcp/pendingMcpInput.js'; +import { + claimMcpInputFromResults, + mcpInputReplyLabel, + parseMcpInputReply, +} from './mcp/pendingMcpInput.js'; import { ASK_USER_CHOICE_TOOL_NAME, askUserChoiceToolSpec, @@ -80,6 +96,7 @@ import { QueryDatasetTool, queryDatasetToolSpec, } from './tools/queryDatasetTool.js'; +import { sortByToolName } from './toolOrdering.js'; import { parseAttachmentsInfo } from './attachmentsInfo.js'; import { checkVisionEmbeddable, @@ -155,7 +172,12 @@ import type { } from './llmProviderSeam.js'; import { streamMessageEvents } from './streaming.js'; import { steeringBus } from './steeringBus.js'; -import { buildDateHeader, today, turnContext } from './turnContext.js'; +import { + buildDateHeader, + today, + turnContext, + type TurnContextValue, +} from './turnContext.js'; import { resolveTurnOwnerIdentity } from './resolveTurnOwnerIdentity.js'; import { isMcpServerPrivacyBypassed } from './mcpPrivacyBypass.js'; import { isMcpServerKgIngest } from './mcpKgIngest.js'; @@ -369,6 +391,24 @@ export interface OrchestratorOptions { * arrives as a normal user message in the next turn. */ askUserChoiceTool?: AskUserChoiceTool; + /** + * W2-1 (#544) — the store an MCP tool's `resultType: "input_required"` result + * is parked in. Wired to the SAME instance the `McpManager` writes to, so the + * orchestrator can drain the turn slot the manager just filled. + * + * Injected rather than owned because the manager lives kernel-side; same + * shape as the sticky Direct Line store (#445). Absent → the whole MRTR path + * is inert and `callTool` degrades an `input_required` result to a plain tool + * error, which is deliberate: half-wired is worse than off. + */ + pendingMcpInput?: PendingMcpInputStore; + /** + * W2-1 (#544) — performs the replay. The orchestrator holds no `McpManager` + * (and must not: it would drag the MCP registry into the kernel), so the + * forced re-call is injected. Absent → a card answer is treated as an + * ordinary user message. + */ + mcpInputReplay?: McpInputReplayer; /** * Optional. When set, exposes the `suggest_follow_ups` tool — non-blocking * 1-click refinement buttons attached below the answer. Used for Top-N, @@ -603,6 +643,58 @@ interface IngestedImageBlock { * supported, since `ingestAttachments` skips fetching entirely otherwise), * so they are simply concatenated when both are non-empty. */ +/** + * W2-1 (#544) — kernel record → channel card payload. + * + * `originalArgs` and `replayDepth` are deliberately NOT copied: the arguments + * may contain data the user never needs to re-see (and a channel has no use for + * them), and the depth is a server-facing guard. Only what a card must render + * crosses the boundary — including `serverName`, which is mandatory. + */ +function toPendingMcpInputCard(record: PendingMcpInput): PendingMcpInputCard { + return { + correlationId: record.correlationId, + serverId: record.serverId, + serverName: record.serverName, + toolName: record.toolName, + ...(record.prompt !== undefined ? { prompt: record.prompt } : {}), + fields: record.inputRequests.map((f) => ({ + name: f.name, + ...(f.label !== undefined ? { label: f.label } : {}), + ...(f.description !== undefined ? { description: f.description } : {}), + ...(f.secret === true ? { secret: true } : {}), + ...(f.required === true ? { required: true } : {}), + })), + }; +} + +/** + * W2-1 (#544) — what the session log records for a turn that ended on an input + * card. Mirrors the `[Rückfrage] …` convention the choice card uses, so a reader + * of the transcript can see WHY the turn produced no answer — and names the + * server, because an audit trail that hides who asked for credentials is worse + * than useless. Field names only; never the values. + */ +function mcpInputCardLogLine(answer: string, card: PendingMcpInputCard): string { + const line = + `[MCP-Eingabe angefordert] "${card.serverName}" → ${card.toolName}: ` + + card.fields.map((f) => f.name).join(', '); + return answer.length > 0 ? `${answer}\n\n${line}` : line; +} + +/** + * W2-1 (#544) — fold the MCP input-replay outcome into the turn's auto-ingested + * trailing text, so the model sees the replayed tool result in the SAME turn the + * user answered the card. Returns `ingestedText` untouched on an ordinary turn. + */ +function withMcpInputNote(ingestedText: string | undefined): string | undefined { + const note = turnContext.current()?.mcpInputReplayNote; + if (note === undefined || note.length === 0) return ingestedText; + return ingestedText !== undefined && ingestedText.trim().length > 0 + ? `${ingestedText}\n\n${note}` + : `\n\n${note}`; +} + function buildUserContent( input: ChatTurnInput, extraText?: string, @@ -1292,6 +1384,148 @@ function mcpObservationDigest(raw: string): string { return `(${String(Buffer.byteLength(raw, 'utf8'))} bytes, values masked)`; } +/** + * Per-tool dispatch deadline (W0-2). Every tool of an iteration is dispatched + * into one `Promise.allSettled` (non-streaming) / race loop (streaming), so a + * single sub-agent that never returns used to pin the WHOLE parallel batch for + * the rest of the turn — there was no per-tool timeout anywhere. + * + * 240s is deliberately generous: a domain sub-agent runs its own multi-iteration + * LLM loop with its own tool calls, so p99 legitimately reaches tens of seconds. + * Operators whose Odoo/Confluence sub-agents run longer raise it via + * `OMADIA_TOOL_DISPATCH_TIMEOUT_MS`; `0` disables the deadline entirely. + * + * ── ORDERING INVARIANT (W3-A) ─────────────────────────────────────────────── + * This is the OUTER bound. It must stay strictly LOOSER than the innermost MCP + * bound — `OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS` (default 180 s, see + * `DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS` in `mcp/mcpClient.ts`), which itself + * sits above the 60 s per-request idle budget. + * + * The default was 120 s, i.e. INSIDE the 180 s MCP ceiling. An MCP-backed + * sub-agent legitimately streaming progress notifications for its full + * allowance was therefore killed by the OUTER bound first: the tighter schranke + * was the outer one, which is backwards, and the model got a generic + * dispatch-deadline error instead of the MCP layer's own diagnosis (which the + * audit trail records as an `fail`/`timeout` row against the server). + * + * The invariant is enforced in PRODUCTION, not only in a test. + * {@link assertTimeoutHierarchy} used to exist ONLY as a local helper inside + * `test/orchestrator/timeoutHierarchy.test.ts` that nothing shipped ever + * called — so `OMADIA_TOOL_DISPATCH_TIMEOUT_MS=90000` re-created the exact + * inversion W3-A removed, with fully green CI, and the symptom surfaced much + * later as MCP calls dying on a generic dispatch-deadline error. It now lives + * here and runs at boot, so an incoherent deployment refuses to start. + * + * The invariant is stated against the MCP layer's `worstCaseTotalMs` — retries + * INCLUDED — because the per-attempt ceiling was never the number that mattered. + * + * `resolveToolDispatchTimeoutMs` is deliberately left as a pure resolver rather + * than clamping to a coherent value: silently substituting a number the operator + * did not choose hides the misconfiguration instead of reporting it, and a short + * deadline is legitimate for a deployment that also lowers the MCP ceiling. The + * boot check is where an incoherent pair is refused. + */ +const DEFAULT_TOOL_DISPATCH_TIMEOUT_MS = 240_000; +const TOOL_DISPATCH_TIMEOUT_ENV = 'OMADIA_TOOL_DISPATCH_TIMEOUT_MS'; + +/** Resolved per dispatch (not cached at module load) so an operator env change + * applies to the next turn without a restart. Exported so the timeout-hierarchy + * invariant check reads the REAL resolved value, env overrides included. */ +export function resolveToolDispatchTimeoutMs(): number { + const raw = process.env[TOOL_DISPATCH_TIMEOUT_ENV]; + if (raw === undefined || raw.trim() === '') { + return DEFAULT_TOOL_DISPATCH_TIMEOUT_MS; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + console.warn( + `[orchestrator] ${TOOL_DISPATCH_TIMEOUT_ENV}="${raw}" is not a non-negative number — using the ${String(DEFAULT_TOOL_DISPATCH_TIMEOUT_MS)}ms default.`, + ); + return DEFAULT_TOOL_DISPATCH_TIMEOUT_MS; + } + return parsed; +} + +/** + * Fail-fast configuration check for the tool-timeout hierarchy — the PRODUCTION + * home of the invariant. Called at boot from `src/index.ts`. + * + * Throws, rather than warning, because every alternative is worse: a warning is + * invisible in a container log, and clamping runs the deployment on a number + * nobody chose. Both knobs are operator-set, so the operator is exactly who can + * fix it, and startup is exactly when they are looking. + */ +export function assertTimeoutHierarchy(): void { + const { timeoutMs, maxTotalTimeoutMs, worstCaseTotalMs } = resolveMcpCallTimeouts(); + if (maxTotalTimeoutMs <= timeoutMs) { + throw new Error( + `[orchestrator] timeout hierarchy is incoherent: the absolute MCP ceiling ` + + `(OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS=${String(maxTotalTimeoutMs)}ms) must be looser than the ` + + `per-request idle budget (OMADIA_MCP_CALL_TIMEOUT_MS=${String(timeoutMs)}ms).`, + ); + } + // `0` means "no dispatch deadline", which is looser than any finite ceiling. + const configured = resolveToolDispatchTimeoutMs(); + if (configured !== 0 && configured <= worstCaseTotalMs) { + throw new Error( + `[orchestrator] timeout hierarchy is incoherent: the OUTER tool-dispatch deadline ` + + `(${TOOL_DISPATCH_TIMEOUT_ENV}=${String(configured)}ms) must be strictly looser than the MCP layer's ` + + `worst-case call budget (${String(worstCaseTotalMs)}ms, retries included) — otherwise an MCP call that ` + + `legitimately uses its full allowance is killed by the outer bound first and the model gets a generic ` + + `dispatch-deadline error instead of the MCP layer's own diagnosis. Raise ${TOOL_DISPATCH_TIMEOUT_ENV} ` + + `above ${String(worstCaseTotalMs)}ms, or lower OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS.`, + ); + } +} + +/** Model-facing result for a tool that blew its deadline. `Error:`-prefixed so + * both dispatch loops key `is_error` off it exactly like any other failure. */ +function toolDeadlineError(name: string, timeoutMs: number): string { + const seconds = (timeoutMs / 1000).toFixed(timeoutMs % 1000 === 0 ? 0 : 1); + return `Error: tool \`${name}\` was aborted after exceeding its ${seconds}s dispatch deadline. Its result (if it ever arrives) is discarded. Continue without it or retry with a narrower request.`; +} + +/** Returned by the abandoned dispatch when it finally settles. Never reaches + * the model — the turn already took {@link toolDeadlineError} for this slot. */ +const TOOL_DISPATCH_DISCARDED = '__omadia_tool_dispatch_discarded__'; + +/** + * Wrap a slot observer so sub-agent events emitted AFTER the deadline are + * dropped. A sub-agent that keeps running past its abort would otherwise keep + * pushing `sub_tool_use`/`sub_tool_result` events into a turn that already + * moved on — the same late-write class the discarded result guards against. + */ +function abortGuardedObserver( + observer: AskObserver | undefined, + signal: AbortSignal, +): AskObserver | undefined { + if (observer === undefined) return undefined; + const gate = + (fn: ((ev: E) => void) | undefined): ((ev: E) => void) | undefined => + fn === undefined + ? undefined + : (ev: E): void => { + if (signal.aborted) return; + fn.call(observer, ev); + }; + const onIteration = gate(observer.onIteration); + const onSubToolUse = gate(observer.onSubToolUse); + const onSubToolResult = gate(observer.onSubToolResult); + const onIterationPhase = gate(observer.onIterationPhase); + const onTokenChunk = gate(observer.onTokenChunk); + const onIterationUsage = gate(observer.onIterationUsage); + const onIterationEnd = gate(observer.onIterationEnd); + return { + ...(onIteration ? { onIteration } : {}), + ...(onSubToolUse ? { onSubToolUse } : {}), + ...(onSubToolResult ? { onSubToolResult } : {}), + ...(onIterationPhase ? { onIterationPhase } : {}), + ...(onTokenChunk ? { onTokenChunk } : {}), + ...(onIterationUsage ? { onIterationUsage } : {}), + ...(onIterationEnd ? { onIterationEnd } : {}), + }; +} + export class Orchestrator { /** The Agent (orchestrator instance) this object serves. */ readonly agentId: string; @@ -1338,6 +1572,9 @@ export class Orchestrator { /** #133 E0 — optional side-channel turn-hook runner (see OrchestratorOptions). */ private readonly turnHookRegistry: TurnHookRunner | undefined; private readonly askUserChoiceTool: AskUserChoiceTool | undefined; + /** W2-1 (#544) — see OrchestratorOptions.pendingMcpInput / mcpInputReplay. */ + private readonly pendingMcpInput: PendingMcpInputStore | undefined; + private readonly mcpInputReplay: McpInputReplayer | undefined; private readonly suggestFollowUpsTool: SuggestFollowUpsTool | undefined; /** #268 — byte source for attachments; drives auto-ingest + read_attachment. */ private readonly attachmentReader: AttachmentReader | undefined; @@ -1441,6 +1678,8 @@ export class Orchestrator { this.factExtractor = options.factExtractor; this.chatParticipantsTool = options.chatParticipantsTool; this.askUserChoiceTool = options.askUserChoiceTool; + this.pendingMcpInput = options.pendingMcpInput; + this.mcpInputReplay = options.mcpInputReplay; this.suggestFollowUpsTool = options.suggestFollowUpsTool; this.attachmentReader = options.attachmentReader; this.readAttachmentTool = options.attachmentReader @@ -1812,6 +2051,205 @@ export class Orchestrator { return this.askUserChoiceTool?.takePending(); } + /** + * W2-1 (#544) — collect an MCP tool call that parked on + * `resultType: "input_required"` during this tool batch. + * + * Sibling of `drainPendingChoice` and drained through the SAME short-circuit + * code path: a non-undefined return terminates the turn so the channel can + * render an input card, and the answer arrives as a fresh turn. + * + * Unlike `askUserChoiceTool`, the pending state cannot live on an instance + * field. The store is shared with the kernel's single `McpManager` across + * every concurrent turn, so the drain is keyed on the turn id — see + * `takePending(turnId)`. + */ + private drainPendingMcpInput( + toolResults: ContentBlock[], + input: ChatTurnInput, + turnId: string, + ): PendingMcpInput | undefined { + if (!this.pendingMcpInput) return undefined; + const strings: string[] = []; + for (const block of toolResults) { + if (block.type !== 'tool_result') continue; + const shape = block as { content?: unknown; is_error?: boolean }; + // A failed tool call never parked anything; skip it for the same reason + // `extractToolEmittedChoice` does. + if (shape.is_error === true) continue; + if (typeof shape.content === 'string') strings.push(shape.content); + } + if (strings.length === 0) return undefined; + // The owner is bound HERE, from the turn input the orchestrator holds + // reliably on both paths — never from ambient context, which the streaming + // path cannot provide. `sessionScope ?? turnId` mirrors the sessionId every + // other turn-scoped consumer uses, and is only ONE component of the key. + return claimMcpInputFromResults(this.pendingMcpInput, strings, { + userId: input.userId ?? null, + sessionId: input.sessionScope ?? turnId, + }); + } + + /** + * W2-1 (#544) — resolve a card answer and REPLAY the parked MCP tool call. + * + * Called once at turn start, before the model runs. Deliberately an + * orchestrator-driven forced call rather than a prompt that hopes the model + * re-calls the tool with the right arguments: the arguments are already known + * exactly (`originalArgs` + the collected `inputResponses`), so leaving the + * choice to the model could only make it wrong. + * + * Returns a note to append to the user's wire message so the model can narrate + * the outcome in this same turn, or `undefined` for an ordinary message. + * + * ## The stdio caveat, stated where it bites + * + * This is a NEW `tools/call` in a LATER turn against a possibly reconnected + * transport — not the in-flight retry MRTR describes. Fine for a stateless + * HTTP server; wrong for a stdio server holding process state tied to the + * original call. See `pendingMcpInput.ts` for why turn suspension is not on + * the table. + */ + private async applyMcpInputReplay( + reply: McpInputReply, + input: ChatTurnInput, + turnId: string, + ): Promise { + const note = await this.runMcpInputReplay(reply, input, turnId); + if (note === undefined) return; + // Written onto the LIVE context store so the wire-message assembly below + // (both the buffered and the streaming path) picks it up without another + // parameter on three nested signatures. + const ctx = turnContext.current(); + if (ctx) ctx.mcpInputReplayNote = note; + } + + private async runMcpInputReplay( + reply: McpInputReply, + input: ChatTurnInput, + turnId: string, + ): Promise { + const store = this.pendingMcpInput; + const replayer = this.mcpInputReplay; + if (!store || !replayer) { + // The user answered a card this deployment can no longer honour (feature + // turned off between the two turns, or a restart cleared the store). + return ( + '[MCP-Eingabe] Die Eingabe konnte nicht übermittelt werden — die ' + + 'Anfrage existiert nicht mehr. Sag dem User, dass er die Aktion neu ' + + 'starten muss, und ruf kein Tool auf.' + ); + } + // The full triple. A card answer arriving with a correlation id that was + // parked under a DIFFERENT user or session simply misses — that miss is the + // #445 defence, so it must never be widened to "look it up by id". + const record = store.take({ + userId: input.userId ?? null, + sessionId: input.sessionScope ?? turnId, + correlationId: reply.correlationId, + }); + if (!record) { + // Expired, already used, or not ours. Say so plainly rather than + // pretending the input was delivered. + return ( + '[MCP-Eingabe] Die Eingabeanfrage ist nicht mehr gültig (abgelaufen oder ' + + 'schon beantwortet). Sag dem User, dass er die Aktion neu starten muss, ' + + 'und ruf kein Tool auf.' + ); + } + let result: string | undefined; + try { + result = await replayer.replay(record, reply.inputResponses); + } catch (err) { + console.error( + '[orchestrator] MCP input replay failed:', + err instanceof Error ? err.message : err, + ); + result = undefined; + } + if (result === undefined) { + return ( + `[MCP-Eingabe] Der Server "${record.serverName}" ist nicht mehr erreichbar, ` + + `die Eingaben für "${record.toolName}" konnten nicht übermittelt werden. ` + + 'Sag das dem User und ruf kein Tool auf.' + ); + } + const guardedResult = await this.guardReplayResult(record, result); + // The collected VALUES are deliberately absent from this note: they may be + // secrets the user typed for the server, and this text goes on the LLM wire + // and into the session log. Only the outcome travels. + return ( + `[MCP-Eingabe] Die Angaben des Users wurden an "${record.serverName}" ` + + `übermittelt und "${record.toolName}" erneut ausgeführt. Ergebnis:\n${guardedResult}\n` + + 'Formuliere daraus die Antwort für den User. Ruf das Tool nicht noch einmal auf.' + ); + } + + /** + * Privacy Shield v4 boundary for MCP input replay: this note crosses only the + * server ↔ LLM-provider seam. The browser stays on the trusted side and is + * unaffected — it may still render the real values server-side. + * + * Shape (b) ("route replay through dispatchTool") was rejected and must stay + * rejected here: the parked record keeps the RAW MCP tool name while + * `dispatchTool` keys on the hydrated native/namespaced one; replay must use + * the server's LIVE config rather than a hydration-time closure; it must stay + * reachable even when the tool is no longer granted/hydrated; and it must not + * re-enter dispatch-only deadline/audit/park semantics. So replay resolves the + * live call where it already does today and applies the SAME privacy boundary + * here, immediately before the note is put on the LLM wire. + * + * Fail-open is deliberate parity with ordinary dispatch: if receipt recording + * or interning throws, we warn and continue with the raw result rather than + * breaking the turn after the user already supplied the requested input. + */ + private async guardReplayResult( + record: PendingMcpInput, + rawResult: string, + ): Promise { + const privacy = turnContext.current()?.privacyHandle; + if (privacy === undefined) return rawResult; + + if (isMcpServerPrivacyBypassed(record.serverId)) { + const effective = resolveEffectivePrivacyMode({ + storedMode: 'bypass', + storedScopes: undefined, + toolName: record.toolName, + env: process.env, + }); + if (effective === 'bypass') { + try { + await privacy.recordBypassedTool({ + toolName: record.toolName, + pluginId: mcpDomainForServer(record.serverName), + reason: 'operator_setting', + bytes: Buffer.byteLength(rawResult, 'utf8'), + }); + } catch (err) { + console.warn( + `[orchestrator.mcpInputReplay:${record.serverId}:${record.toolName}] privacy.recordBypassedTool threw — bypass still applied:`, + err, + ); + } + return rawResult; + } + } + + try { + const v4 = await privacy.internToolResultV4({ + toolName: record.toolName, + rawResult, + }); + return v4.digestText; + } catch (err) { + console.warn( + `[orchestrator.mcpInputReplay:${record.serverId}:${record.toolName}] privacy.internToolResultV4 threw — sending raw replay result:`, + err, + ); + return rawResult; + } + } + /** * OB-29-4 — scan plugin-tool result strings for an in-band * `_pendingUserChoice` payload. Plugins (which have no kernel-internal @@ -2234,6 +2672,15 @@ export class Orchestrator { async runTurn(input: ChatTurnInput): Promise { const turnId = randomUUID(); + // W2-1 (#544) — an MCP input card's answer arrives as a machine envelope in + // `userMessage`. Normalise it HERE, before anything downstream reads the + // field, so the envelope never reaches the session log, memory, the KG, the + // privacy receipt or the chat transcript. The replay itself runs inside the + // turn scope below (it needs the turn context for audit attribution). + const mcpInputReply = parseMcpInputReply(input.userMessage); + if (mcpInputReply) { + input = { ...input, userMessage: mcpInputReplyLabel(mcpInputReply) }; + } // Inherit optional fields the channel adapter (e.g. Teams bot) set in an // outer ALS scope. The new child scope replaces turnId/turnDate for this // turn; carry-through fields like `chatParticipants` must be threaded @@ -2261,6 +2708,45 @@ export class Orchestrator { input, ); + // ── W4-1 — the missing `mcpUserKey` producer for CHANNEL turns ────────── + // HTTP routes establish the identity in an outer scope (see + // `middleware/src/routes/chat.ts`) and that ALWAYS wins. A channel turn + // (Teams/Telegram/Slack) has no session to read, so the canonical omadia + // user id resolved just above IS the caller identity — without this, every + // `per_user` MCP server audits the call as `unresolved` and fails closed on + // every channel turn. + // + // Chosen over resolving at the adapter (`createOrchestratorDispatcher`) + // because the adapter holds no `KnowledgeGraph`: doing it there means a new + // dependency, new boot wiring, and a SECOND identity round-trip per turn + // for a value this scope has already computed. + // + // Gated on `input.channelIdentity` — NOT applied to every resolved id. + // With no `channelIdentity`, `resolveTurnOwnerIdentity` returns + // `input.userId` verbatim, and on the HTTP path that can originate in the + // client-controlled `x-user-id` header (`chat.ts`'s `resolveUserId`). + // Keying MCP tokens on it would let any caller act as any user — W0-1's + // confused deputy, re-opened one door along. A `channelIdentity` is minted + // only by `createOrchestratorDispatcher` from the adapter's authenticated + // `userRef` and is resolved through the KG. + // + // Precisely how far that attestation reaches: the dispatcher copies + // `userRef.id` verbatim and verifies nothing itself, so the guarantee is + // exactly as strong as the inbound-webhook authentication in the Teams / + // Telegram / Slack adapters — which live outside this repo. It is + // adapter-attested, not attested here. Bounded, though: + // `resolveOrCreateChannelIdentity` creates on miss, so a forged id matching + // no known identity mints a fresh uuid holding no token and fails closed. + // Impersonation needs an already-known channel user id. + // `||`, not `??`: every other link in this chain guards on truthiness (the + // spread below, `chat.ts`'s producer, `turnContext`'s carry-over). With + // `??`, a parent carrying an empty string would short-circuit, suppress the + // valid key this branch would have produced, and then be dropped by the + // truthy spread — silently downgrading a resolvable turn to `unresolved`. + const mcpUserKey = + parent?.mcpUserKey || + (input.channelIdentity ? resolvedOmadiaUserId : undefined); + return turnContext.run( { turnId, @@ -2272,9 +2758,17 @@ export class Orchestrator { // per-user data with it. ...(input.userId ? { userId: input.userId } : {}), ...(resolvedOmadiaUserId ? { resolvedOmadiaUserId } : {}), + // W2-1 (#544) — one component of the MCP pending-input store key. Never + // the whole key; see TurnContextValue.sessionScope. + sessionScope: sessionId, ...(parent?.chatParticipants ? { chatParticipants: parent.chatParticipants } : {}), + // W3-A — MCP OAuth caller identity. Read by the auth provider's + // `getToken` + `resolveIdentity`. Without it a `per_user` server audits + // every call as `unresolved` and then fails closed. See the W4-1 block + // above for where the value comes from. + ...(mcpUserKey ? { mcpUserKey } : {}), ...(privacyHandle ? { privacyHandle } : {}), ...(parent?.captureRawToolResult ? { captureRawToolResult: parent.captureRawToolResult } @@ -2287,6 +2781,11 @@ export class Orchestrator { : {}), }, async () => { + // W2-1 (#544) — forced replay of the parked MCP tool call, before the + // model runs. Writes its outcome onto the live turn context. + if (mcpInputReply) { + await this.applyMcpInputReplay(mcpInputReply, input, turnId); + } // #332 Layer 2 — Direct Line short-circuit (non-streaming / Teams // path). A user-directed specialist turn is dispatched deterministically // by the harness; the orchestrator LLM never runs. Still flows through @@ -3121,7 +3620,12 @@ export class Orchestrator { role: 'user', content: buildUserContent( input, - ingestedText, + // W2-1 (#544) — appends the MCP replay outcome, when this turn was an + // input-card answer. Applied AFTER masking on purpose: the note is + // orchestrator-authored prose with no user PII in it (values are + // deliberately excluded), and re-masking it would only garble the + // server name the model needs in order to attribute the result. + withMcpInputNote(ingestedText), wireUserMessage, ingestedImages, visionSupported, @@ -3534,6 +4038,25 @@ export class Orchestrator { this.drainPendingChoice() ?? this.extractToolEmittedChoice(toolResults), ); + // W2-1 (#544) — the MCP input card rides the SAME short-circuit. + // + // DETERMINISTIC WINNER: `pendingUserChoice` wins whenever both are + // pending in one batch. Two reasons, in order: + // 1. Precedence must not depend on tool-dispatch order, and it must + // not change existing behaviour — `ask_user_choice` shipped first + // and its short-circuit is what every channel already renders. + // 2. A model that asked its own clarifying question has decided it + // does not yet understand the request; collecting server-specific + // field values first would be answering the wrong question. + // The MCP record is NOT discarded when it loses: the turn slot is + // drained (so it cannot leak into a later turn's card) but the keyed + // record stays replayable until its TTL, so the model can resume after + // the clarification instead of the parked call vanishing. + const pendingMcpInputCard = this.drainPendingMcpInput( + toolResults, + input, + turnId, + ); if (pendingUserChoice) { this.drainAttachments(); // Follow-up suggestions are incompatible with a blocking choice @@ -3593,6 +4116,57 @@ export class Orchestrator { ...(recalled ? { recalled } : {}), }; } + // W2-1 (#544) — MCP input card. Same drain-and-terminate shape as the + // choice card above; only reached when no choice card won. + if (pendingMcpInputCard) { + this.drainAttachments(); + this.drainFollowUps(); + this.drainPendingSlotCard(); + this.drainPendingRoutineList(); + const card = toPendingMcpInputCard(pendingMcpInputCard); + const answer = textParts.join('\n\n').trim(); + const restoredAnswer = await restorePromptForPersistence( + privacyForPrompt, + answer, + ); + const iterations = iteration + 1; + const runTrace = traceCollector?.finish({ + iterations, + status: 'success', + }); + let persistedTurnId: string | undefined; + if (this.sessionLogger && input.sessionScope) { + const entityRefs = entityCollection?.drain() ?? []; + const loggedAnswer = mcpInputCardLogLine(restoredAnswer, card); + try { + const logged = await this.sessionLogger.log({ + scope: input.sessionScope, + userMessage: input.userMessage, + assistantAnswer: loggedAnswer, + toolCalls, + iterations, + entityRefs, + ...(input.userId ? { userId: input.userId } : {}), + ...(runTrace ? { runTrace } : {}), + }); + persistedTurnId = logged.turnExternalId; + } catch (err) { + console.error( + '[orchestrator] session log failed (continuing with MCP input card):', + err instanceof Error ? err.message : err, + ); + } + } + return { + answer: restoredAnswer, + toolCalls, + iterations, + pendingMcpInput: card, + ...(persistedTurnId ? { turnId: persistedTurnId } : {}), + ...(runTrace ? { runTrace } : {}), + ...(recalled ? { recalled } : {}), + }; + } } throw new Error( @@ -3618,10 +4192,22 @@ export class Orchestrator { observer?: AskObserver, ): AsyncGenerator { const turnId = randomUUID(); - // `enter` (not `run`) because AsyncLocalStorage.run doesn't compose with - // async generators. `enter` binds turnId to the current async resource, - // which the generator's awaits inherit; scope ends when the HTTP request - // resource is cleaned up. + // W2-1 (#544) — mirror of `runTurn`: normalise the input-card envelope + // before any downstream reader sees it. See the comment there. + const mcpInputReply = parseMcpInputReply(input.userMessage); + if (mcpInputReply) { + input = { ...input, userMessage: mcpInputReplyLabel(mcpInputReply) }; + } + // W3-A — this used to be `turnContext.enter` (AsyncLocalStorage.enterWith). + // That does NOT survive a generator's first `yield`: the generator is + // resumed in the async context of whoever called `.next()`, so by the time + // the tool loop ran, `turnContext.current()` was empty (or, worse, bound to + // the consumer's ambient scope). Everything that reads the turn context at + // dispatch time was therefore broken on every streaming turn — MCP audit + // attribution (`callerKind`/`turnId`/`callerAgent`/`mcpUserKey`), the + // skill-binding persona gate, the privacy handle, the KG-ingest owner. + // The body now runs through `turnContext.runGenerator`, which wraps every + // advance of the inner generator in `storage.run`. const parent = turnContext.current(); // Privacy-Proxy Slice 2.1: same handle pattern as `runTurn`. The handle @@ -3644,15 +4230,62 @@ export class Orchestrator { input, ); - turnContext.enter({ + // ── W4-1 — the missing `mcpUserKey` producer for CHANNEL turns ────────── + // HTTP routes establish the identity in an outer scope (see + // `middleware/src/routes/chat.ts`) and that ALWAYS wins. A channel turn + // (Teams/Telegram/Slack) has no session to read, so the canonical omadia + // user id resolved just above IS the caller identity — without this, every + // `per_user` MCP server audits the call as `unresolved` and fails closed on + // every channel turn. + // + // Chosen over resolving at the adapter (`createOrchestratorDispatcher`) + // because the adapter holds no `KnowledgeGraph`: doing it there means a new + // dependency, new boot wiring, and a SECOND identity round-trip per turn + // for a value this scope has already computed. + // + // Gated on `input.channelIdentity` — NOT applied to every resolved id. + // With no `channelIdentity`, `resolveTurnOwnerIdentity` returns + // `input.userId` verbatim, and on the HTTP path that can originate in the + // client-controlled `x-user-id` header (`chat.ts`'s `resolveUserId`). + // Keying MCP tokens on it would let any caller act as any user — W0-1's + // confused deputy, re-opened one door along. A `channelIdentity` is minted + // only by `createOrchestratorDispatcher` from the adapter's authenticated + // `userRef` and is resolved through the KG. + // + // Precisely how far that attestation reaches: the dispatcher copies + // `userRef.id` verbatim and verifies nothing itself, so the guarantee is + // exactly as strong as the inbound-webhook authentication in the Teams / + // Telegram / Slack adapters — which live outside this repo. It is + // adapter-attested, not attested here. Bounded, though: + // `resolveOrCreateChannelIdentity` creates on miss, so a forged id matching + // no known identity mints a fresh uuid holding no token and fails closed. + // Impersonation needs an already-known channel user id. + // `||`, not `??`: every other link in this chain guards on truthiness (the + // spread below, `chat.ts`'s producer, `turnContext`'s carry-over). With + // `??`, a parent carrying an empty string would short-circuit, suppress the + // valid key this branch would have produced, and then be dropped by the + // truthy spread — silently downgrading a resolvable turn to `unresolved`. + const mcpUserKey = + parent?.mcpUserKey || + (input.channelIdentity ? resolvedOmadiaUserId : undefined); + + const context: TurnContextValue = { turnId, turnDate: today(), // Per-orchestrator isolation: see the matching `turnContext.run` above. agentSlug: this.agentId, + // The streaming path never set `userId` — W2-1 needs it, because the MCP + // pending-input key must bind a parked record to the human who will + // answer the card, and channel turns (Teams/Telegram) come through here. + ...(input.userId ? { userId: input.userId } : {}), ...(resolvedOmadiaUserId ? { resolvedOmadiaUserId } : {}), + // W2-1 (#544) — see the matching `turnContext.run` above. + sessionScope: sessionId, ...(parent?.chatParticipants ? { chatParticipants: parent.chatParticipants } : {}), + // W3-A / W4-1 — see the matching `turnContext.run` above. + ...(mcpUserKey ? { mcpUserKey } : {}), ...(privacyHandle ? { privacyHandle } : {}), ...(parent?.captureRawToolResult ? { captureRawToolResult: parent.captureRawToolResult } @@ -3662,9 +4295,43 @@ export class Orchestrator { ...(parent?.canvasSentinelSink ? { canvasSentinelSink: parent.canvasSentinelSink } : {}), - }); + }; + // `input` is re-bound above (envelope normalisation); capture the final + // value so the body cannot observe the pre-normalisation message. + const turnInput = input; + yield* turnContext.runGenerator(context, () => + this.chatStreamInContext({ + input: turnInput, + turnId, + sessionId, + mcpInputReply, + ...(privacyHandle ? { privacyHandle } : {}), + ...(observer ? { observer } : {}), + }), + ); + } + + /** + * The body of {@link chatStream}, run inside the turn's AsyncLocalStorage + * scope by `turnContext.runGenerator`. Split out purely so the context can be + * established with `run()` semantics instead of `enterWith` — see the comment + * at the top of `chatStream`. + */ + private async *chatStreamInContext(args: { + readonly input: ChatTurnInput; + readonly turnId: string; + readonly sessionId: string; + readonly mcpInputReply: McpInputReply | undefined; + readonly privacyHandle?: PrivacyTurnHandle; + readonly observer?: AskObserver; + }): AsyncGenerator { + const { input, turnId, sessionId, mcpInputReply, privacyHandle, observer } = args; this.applyTurnAuthContext(input); + // W2-1 (#544) — forced replay before the model runs. Mirror of `runTurn`. + if (mcpInputReply) { + await this.applyMcpInputReplay(mcpInputReply, input, turnId); + } // #133 E0 — streaming-path turn hooks. tool_result events carry only the // tool-use id, so track id→name from tool_use events to label // onAfterToolCall. @@ -3928,7 +4595,12 @@ export class Orchestrator { role: 'user', content: buildUserContent( input, - ingestedText, + // W2-1 (#544) — appends the MCP replay outcome, when this turn was an + // input-card answer. Applied AFTER masking on purpose: the note is + // orchestrator-authored prose with no user PII in it (values are + // deliberately excluded), and re-masking it would only garble the + // server name the model needs in order to attribute the result. + withMcpInputNote(ingestedText), wireUserMessage, ingestedImages, visionSupported, @@ -4494,6 +5166,13 @@ export class Orchestrator { this.drainPendingChoice() ?? this.extractToolEmittedChoice(toolResults), ); + // W2-1 (#544) — mirror of chatInContextInner, including the + // deterministic winner rule. See the comment there. + const pendingMcpInputCard = this.drainPendingMcpInput( + toolResults, + input, + turnId, + ); if (pendingUserChoice) { this.drainAttachments(); // Follow-up suggestions are incompatible with a blocking choice @@ -4556,6 +5235,64 @@ export class Orchestrator { }; return; } + // W2-1 (#544) — MCP input card, streaming mirror. + if (pendingMcpInputCard) { + this.drainAttachments(); + this.drainFollowUps(); + this.drainPendingSlotCard(); + const card = toPendingMcpInputCard(pendingMcpInputCard); + const answer = textParts.join('\n\n').trim(); + const restoredAnswer = await restorePromptForPersistence( + privacyForPrompt, + answer, + ); + const iterations = iteration + 1; + const runTrace = traceCollector?.finish({ + iterations, + status: 'success', + }); + let persistedTurnId: string | undefined; + if (this.sessionLogger && input.sessionScope) { + const entityRefs = entityCollection?.drain() ?? []; + const loggedAnswer = mcpInputCardLogLine(restoredAnswer, card); + try { + const logged = await this.sessionLogger.log({ + scope: input.sessionScope, + userMessage: input.userMessage, + assistantAnswer: loggedAnswer, + toolCalls, + iterations, + entityRefs, + ...(input.userId ? { userId: input.userId } : {}), + ...(runTrace ? { runTrace } : {}), + }); + persistedTurnId = logged.turnExternalId; + } catch (err) { + console.error( + '[orchestrator] session log failed (continuing with MCP input card):', + err instanceof Error ? err.message : err, + ); + } + } + const mcpAgentsConsulted = deriveAgentsConsulted(runTrace); + yield { + type: 'done', + answer: restoredAnswer, + toolCalls, + iterations, + model: turnModel, + pendingMcpInput: card, + ...(persistedTurnId ? { turnId: persistedTurnId } : {}), + ...(runTrace ? { runTrace } : {}), + ...(mcpAgentsConsulted && mcpAgentsConsulted.length > 0 + ? { agentsConsulted: mcpAgentsConsulted } + : {}), + ...(this.directLineSticky + ? { directLineSession: { active: false } as const } + : {}), + }; + return; + } } yield { @@ -4772,10 +5509,63 @@ export class Orchestrator { } } + /** + * W0-2 — every tool dispatch runs under a per-tool deadline. Without it a + * single hung sub-agent (`domainQueryTool` awaits `agent.ask()` with no + * abort) blocks the entire `Promise.allSettled` batch for the whole turn. + * + * On timeout the slot resolves with a structured `Error:` string and the + * abandoned dispatch is marked aborted, so when it eventually settles its + * result is DISCARDED instead of being written into a turn that moved on + * (raw-result capture, privacy interning, KG ingestion, sub-events). + * + * The deadline is per tool, not per batch: sibling tools in the same + * `allSettled` keep running and resolve normally. + */ private async dispatchTool( name: string, input: unknown, observer?: AskObserver, + ): Promise { + const timeoutMs = resolveToolDispatchTimeoutMs(); + if (timeoutMs === 0) { + // Deadline explicitly disabled by the operator — legacy behaviour. + return this.dispatchToolDeadlined(name, input, observer); + } + const controller = new AbortController(); + const work = this.dispatchToolDeadlined( + name, + input, + abortGuardedObserver(observer, controller.signal), + controller.signal, + ); + // A dispatch that rejects AFTER the deadline already resolved the race + // would otherwise surface as an unhandled rejection and kill the process. + work.catch(() => undefined); + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort(); + console.warn( + `[orchestrator.dispatchTool:${name}] exceeded the ${String(timeoutMs)}ms dispatch deadline — aborting this slot; siblings are unaffected.`, + ); + resolve(toolDeadlineError(name, timeoutMs)); + }, timeoutMs); + // Never hold the event loop open just to police a deadline. + timer.unref?.(); + }); + try { + return await Promise.race([work, deadline]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } + + private async dispatchToolDeadlined( + name: string, + input: unknown, + observer?: AskObserver, + deadlineSignal?: AbortSignal, ): Promise { // Privacy Shield v4 — Data-Plane Boundary. The privacy handle is // threaded through `turnContext.privacyHandle`; absent ⇒ no privacy @@ -4828,6 +5618,35 @@ export class Orchestrator { } else { result = await this.dispatchToolInner(name, input, observer); } + // W0-2 — late-result firewall. The deadline already fired for this slot: + // the turn took `toolDeadlineError` and moved on. Everything below this + // line WRITES this result into turn state (raw-result capture, canvas + // sentinel tap, KG ingestion, privacy interning/bypass receipts), so a + // late arrival is dropped HERE, before the first of THOSE side effects. + // + // WHAT THIS DOES NOT DO — the guard sits AFTER `dispatchToolInner` returns, + // so everything the tool did on its way to producing this result has already + // happened and is not undone: + // - MCP mutations on the remote server, and their `mcp_call_log` rows; + // - knowledge-graph and memory writes performed by the tool itself; + // - datasets a sub-agent interned via `privacy.internToolResultV4`, which + // register on the TURN's `privacyHandle` and therefore outlive this + // abort (the local `subAgentSink` array is discarded with the slot, but + // the registration is not — the privacy contract exposes no per-dataset + // drop, only `finalizeTurn`, so unwinding it needs a new API on the + // published `@omadia/plugin-api` surface, not a change here). + // So a `knowledge_graph` write that took 241 s IS in the graph, while the + // model was told the call was aborted and its result discarded. What is + // guaranteed is narrower and worth stating exactly: the late result never + // enters TURN state, and the model never sees it. Side effects the tool + // already committed are outside this boundary by construction — cancelling + // them would need cooperative aborts all the way down. + if (deadlineSignal?.aborted === true) { + console.warn( + `[orchestrator.dispatchTool:${name}] result arrived after the dispatch deadline — discarded.`, + ); + return TOOL_DISPATCH_DISCARDED; + } // Phase C.2 — Raw tool-result capture. Outer scope (routine runner) // may install a callback that stashes the raw result keyed by tool // name; later template rendering uses it as the source of truth for @@ -5652,22 +6471,45 @@ export class Orchestrator { // Issue #474 — a plugin that hasn't finished its own connection/auth // setup is excluded here so the orchestrator never offers a tool it // knows will fail, instead of discovering that via a wasted round-trip. + // + // W0-3 — sorted by name. `listWithHandler()` iterates a Map, so raw order + // is plugin LOAD order, which differs between Fly machines and between + // deploys. That silently invalidated the `cache_control` block stamped at + // the end of this method. Sorting makes the block a function of the tool + // set, not of registration timing. Advertisement order only — dispatch + // still resolves by name, so precedence is unaffected. + const nativeSpecs: unknown[] = []; for (const entry of this.nativeTools.listWithHandler()) { if (entry.spec && this.isToolAvailable(entry.agentId)) { - tools.push(entry.spec); + nativeSpecs.push(entry.spec); } } + for (const spec of sortByToolName( + nativeSpecs as ReadonlyArray<{ readonly name: string }>, + )) { + tools.push(spec); + } // DomainTools dynamically from the map — so hot-registered uploaded // agents become visible from the next iteration without reboot. // Issue #474 — same gate as the native-tools loop above: a domain tool // whose owning plugin hasn't completed its connection/auth setup must // not be offered either, otherwise the model discovers the missing // access via a failing dispatch instead of the tool being absent. + // + // W0-3 — sorted for the same reason as the native segment above; this map + // is populated in `created_at` row order, which is not stable across + // machines that hydrated their registry at different times. + const domainSpecs: unknown[] = []; for (const tool of this.domainToolsByName.values()) { if (this.isToolAvailable(tool.agentId)) { - tools.push(tool.spec); + domainSpecs.push(tool.spec); } } + for (const spec of sortByToolName( + domainSpecs as ReadonlyArray<{ readonly name: string }>, + )) { + tools.push(spec); + } // Privacy-Shield v4 — verb + render tools, offered only when the v4 // data-plane boundary is active for this turn. const v4ToolSpecs = turnContext.current()?.privacyHandle?.v4ToolSpecs(); @@ -5679,6 +6521,11 @@ export class Orchestrator { // marking the final tool makes the whole list a single cacheable chunk. // 5-minute TTL comfortably covers a multi-iteration orchestrator turn, // so iter 2..N skip re-reading the tool definitions on the server side. + // + // W0-3 — the cache keys on a byte-exact prefix, so this only pays off + // because the dynamic segments above are name-sorted. Do not reorder or + // append unsorted segments before this point without re-reading + // `toolOrdering.ts`; a reordered block is a silent, signal-free cache miss. const last = tools[tools.length - 1]; if (last) { tools[tools.length - 1] = { diff --git a/middleware/packages/harness-orchestrator/src/plugin.ts b/middleware/packages/harness-orchestrator/src/plugin.ts index 16171b37..000ba5d9 100644 --- a/middleware/packages/harness-orchestrator/src/plugin.ts +++ b/middleware/packages/harness-orchestrator/src/plugin.ts @@ -55,6 +55,10 @@ import type { ChatSessionStore } from './chatSessionStore.js'; import type { NativeToolRegistry } from './nativeToolRegistry.js'; import type { Orchestrator } from './orchestrator.js'; import { InMemoryDirectLineStickyStore } from './directLineSticky.js'; +import { + sharedMcpInputReplayer, + sharedPendingMcpInputStore, +} from './mcp/pendingMcpInput.js'; import { DEFAULT_ORCHESTRATOR_MODEL } from './registry/agentRuntime.js'; import { ConfigStore } from './registry/configStore.js'; import { @@ -583,6 +587,15 @@ export async function activate( // in deps means toggling the flag at runtime never strands a binding in a // store that has been thrown away. directLineStickyStore: new InMemoryDirectLineStickyStore(), + // W2-1 (#544) — the SAME store instance the kernel's `McpManager` parks + // into, plus the replayer the kernel registered once it had a manager and a + // server registry. Unconditional store (empty until something parks); + // `buildOrchestrator` only enables the path when BOTH are present, so a + // deployment without the kernel wiring stays fully inert. + pendingMcpInput: sharedPendingMcpInputStore(), + ...(sharedMcpInputReplayer() + ? { mcpInputReplay: sharedMcpInputReplayer()! } + : {}), attachmentReader, }; // Per-turn Sonnet/Opus routing (opt-in). When `orchestrator_model_routing` diff --git a/middleware/packages/harness-orchestrator/src/registry/agentGraphStore.ts b/middleware/packages/harness-orchestrator/src/registry/agentGraphStore.ts index 4ea2f5bd..9fcca6f4 100644 --- a/middleware/packages/harness-orchestrator/src/registry/agentGraphStore.ts +++ b/middleware/packages/harness-orchestrator/src/registry/agentGraphStore.ts @@ -2,6 +2,8 @@ import type { Pool } from 'pg'; import { ConfigValidationError, validateModelRef } from './configStore.js'; import { computeSkillHash } from './skillHash.js'; +import type { McpCallerKind } from '../mcp/mcpClient.js'; +import { normalizeDiscoveredToolOrder } from '../toolOrdering.js'; /** * Agent Builder graph store (P0). @@ -212,8 +214,32 @@ export interface McpServerRow { /** Epic #459 — NON-SECRET config values `{ key: value }`. Secrets are in the * Vault, not here. */ readonly config: Record; + /** W0-1 — whose authority MCP calls to this server act under. + * `per_user`: the acting identity must resolve or the call fails closed — + * no silent inheritance of the operator's authority (confused deputy). + * `service`: one shared identity, the explicit opt-in. Migration 0031 sets + * `service` on pre-existing servers that already hold a token so installed + * deployments keep working; only new rows default to `per_user`. */ + readonly delegation: McpDelegation; } +/** How an MCP server resolves the identity a call acts as (W0-1, D2). */ +export type McpDelegation = 'per_user' | 'service'; + +/** + * How omadia acquired the OAuth client it uses at an authorization server + * (migration 0032, W2-4). All three modes are first-class and PERMANENT: + * + * - `cimd` Client ID Metadata Document — the client_id is an https URL the + * AS dereferences. Replaces DCR at MCP-native brokers only, and + * requires the AS to reach omadia INBOUND. + * - `dcr` RFC 7591 Dynamic Client Registration. Deprecated by the MCP spec + * on a 12-month clock; still fully supported here. + * - `manual` An operator-registered app. This is the Entra ID / Okta path — + * neither supports CIMD — and it has NO sunset. + */ +export type McpOAuthClientAcquisition = 'dcr' | 'manual' | 'cimd'; + export interface ToolGrantRow { readonly id: string; readonly agentId: string | null; @@ -435,6 +461,8 @@ interface McpServerDbRow { kg_ingest?: boolean; config_schema?: McpConfigField[]; config?: Record; + // W0-1 delegation mode; absent on pre-0031 rows in tests. + delegation?: McpDelegation; } interface McpRegistryDbRow { @@ -507,13 +535,19 @@ export interface McpCallLogRow { readonly serverId: string | null; readonly serverName: string; readonly toolName: string; - readonly callerKind: 'agent' | 'subagent' | 'skill' | 'plugin' | 'unattributed'; + /** W2-3 (#542) — reuses the shared `McpCallerKind` union rather than a + * retyped copy, which is how the previous copy fell one member behind. */ + readonly callerKind: McpCallerKind; readonly callerAgent: string | null; readonly turnId: string | null; readonly ok: boolean; readonly error: string | null; readonly durationMs: number; readonly calledAt: Date; + /** W0-1 — WHOSE authority the call acted under (the resolved MCP user key, + * or `unresolved` when a `per_user` server had no identity to act as). + * `callerAgent` is the orchestrator slug; this is the identity. */ + readonly actingIdentity: string | null; } interface McpCallLogDbRow { @@ -528,6 +562,7 @@ interface McpCallLogDbRow { error: string | null; duration_ms: number; called_at: Date; + acting_identity?: string | null; } interface ToolGrantDbRow { @@ -758,6 +793,9 @@ function mapMcpServer(r: McpServerDbRow): McpServerRow { kgIngest: r.kg_ingest ?? false, configSchema: Array.isArray(r.config_schema) ? r.config_schema : [], config: r.config ?? {}, + // Pre-0031 rows (and hand-built test fixtures) read as the safe mode; the + // migration is what grandfathers real installed servers into 'service'. + delegation: r.delegation === 'service' ? 'service' : 'per_user', }; } @@ -1360,18 +1398,34 @@ export class AgentGraphStore { // ── MCP OAuth (epic #459 W9) — provider-agnostic authorization state ──────── - async getMcpOAuthClient( - issuer: string, - ): Promise<{ issuer: string; clientId: string; clientSecretRef: string | null; registeredVia: 'dcr' | 'manual' } | undefined> { + async getMcpOAuthClient(issuer: string): Promise< + | { + issuer: string; + clientId: string; + clientSecretRef: string | null; + registeredVia: McpOAuthClientAcquisition; + /** W2-4: the CIMD document this client_id resolves to. Null for + * 'dcr'/'manual' rows and on pre-0032 databases. */ + clientMetadataUrl: string | null; + } + | undefined + > { const { rows } = await this.pool.query<{ issuer: string; client_id: string; client_secret_ref: string | null; - registered_via: 'dcr' | 'manual'; + registered_via: McpOAuthClientAcquisition; + client_metadata_url?: string | null; }>('SELECT * FROM mcp_oauth_clients WHERE issuer = $1', [issuer]); const r = rows[0]; return r - ? { issuer: r.issuer, clientId: r.client_id, clientSecretRef: r.client_secret_ref, registeredVia: r.registered_via } + ? { + issuer: r.issuer, + clientId: r.client_id, + clientSecretRef: r.client_secret_ref, + registeredVia: r.registered_via, + clientMetadataUrl: r.client_metadata_url ?? null, + } : undefined; } @@ -1379,16 +1433,26 @@ export class AgentGraphStore { issuer: string; clientId: string; clientSecretRef: string | null; - registeredVia: 'dcr' | 'manual'; + registeredVia: McpOAuthClientAcquisition; + /** W2-4: only set for `registeredVia: 'cimd'` — the self-referential + * metadata-document URL the authorization server dereferenced. */ + clientMetadataUrl?: string | null; }): Promise { await this.pool.query( - `INSERT INTO mcp_oauth_clients (issuer, client_id, client_secret_ref, registered_via) - VALUES ($1,$2,$3,$4) + `INSERT INTO mcp_oauth_clients (issuer, client_id, client_secret_ref, registered_via, client_metadata_url) + VALUES ($1,$2,$3,$4,$5) ON CONFLICT (issuer) DO UPDATE SET client_id = EXCLUDED.client_id, client_secret_ref = EXCLUDED.client_secret_ref, - registered_via = EXCLUDED.registered_via`, - [input.issuer, input.clientId, input.clientSecretRef, input.registeredVia], + registered_via = EXCLUDED.registered_via, + client_metadata_url = EXCLUDED.client_metadata_url`, + [ + input.issuer, + input.clientId, + input.clientSecretRef, + input.registeredVia, + input.clientMetadataUrl ?? null, + ], ); } @@ -1403,6 +1467,8 @@ export class AgentGraphStore { refreshTokenRef: string | null; expiresAt: Date | null; scopes: string | null; + /** Issuer that minted this token (W0-1) — null on pre-0031 rows. */ + issuer: string | null; } | undefined > { @@ -1413,6 +1479,7 @@ export class AgentGraphStore { refresh_token_ref: string | null; expires_at: Date | null; scopes: string | null; + issuer?: string | null; }>('SELECT * FROM mcp_oauth_tokens WHERE server_id = $1 AND user_key = $2', [serverId, userKey]); const r = rows[0]; return r @@ -1423,6 +1490,7 @@ export class AgentGraphStore { refreshTokenRef: r.refresh_token_ref, expiresAt: r.expires_at, scopes: r.scopes, + issuer: r.issuer ?? null, } : undefined; } @@ -1434,19 +1502,45 @@ export class AgentGraphStore { refreshTokenRef: string | null; expiresAt: Date | null; scopes: string | null; + /** Issuer that minted the token (W0-1) — lets a rotated issuer invalidate + * the stored token instead of replaying it against a different AS. */ + issuer?: string | null; }): Promise { await this.pool.query( `INSERT INTO mcp_oauth_tokens - (server_id, user_key, access_token_ref, refresh_token_ref, expires_at, scopes, updated_at) - VALUES ($1,$2,$3,$4,$5,$6, now()) + (server_id, user_key, access_token_ref, refresh_token_ref, expires_at, scopes, issuer, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7, now()) ON CONFLICT (server_id, user_key) DO UPDATE SET access_token_ref = EXCLUDED.access_token_ref, refresh_token_ref = EXCLUDED.refresh_token_ref, expires_at = EXCLUDED.expires_at, scopes = EXCLUDED.scopes, + issuer = EXCLUDED.issuer, updated_at = now()`, - [input.serverId, input.userKey, input.accessTokenRef, input.refreshTokenRef, input.expiresAt, input.scopes], + [ + input.serverId, + input.userKey, + input.accessTokenRef, + input.refreshTokenRef, + input.expiresAt, + input.scopes, + input.issuer ?? null, + ], + ); + } + + /** Set the delegation mode for a server (W0-1, D2). Returns the updated row, + * or undefined when the server does not exist. */ + async setMcpServerDelegation( + serverId: string, + delegation: McpDelegation, + ): Promise { + const { rows } = await this.pool.query( + 'UPDATE mcp_servers SET delegation = $2, updated_at = now() WHERE id = $1 RETURNING *', + [serverId, delegation], ); + const r = rows[0]; + return r ? mapMcpServer(r) : undefined; } async deleteMcpOAuthToken(serverId: string, userKey: string): Promise { @@ -1475,13 +1569,17 @@ export class AgentGraphStore { scopes: string | null; tokenEndpoint: string; authorizationEndpoint: string; + /** Whether the AS advertised RFC 9207 `authorization_response_iss_parameter_supported` + * at authorize time (W0-1, D1). Captured HERE, never re-discovered at the + * callback — same reasoning as the endpoint binding in migration 0016. */ + issRequired?: boolean; }): Promise { // Opportunistic prune of stale flows (older than 15 min) on each create. await this.pool.query("DELETE FROM mcp_oauth_flows WHERE created_at < now() - interval '15 minutes'"); await this.pool.query( `INSERT INTO mcp_oauth_flows - (state, server_id, user_key, issuer, code_verifier, redirect_uri, scopes, token_endpoint, authorization_endpoint) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + (state, server_id, user_key, issuer, code_verifier, redirect_uri, scopes, token_endpoint, authorization_endpoint, iss_required) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, [ input.state, input.serverId, @@ -1492,6 +1590,7 @@ export class AgentGraphStore { input.scopes, input.tokenEndpoint, input.authorizationEndpoint, + input.issRequired ?? false, ], ); } @@ -1510,6 +1609,9 @@ export class AgentGraphStore { scopes: string | null; tokenEndpoint: string | null; authorizationEndpoint: string | null; + /** The AS advertised RFC 9207 when this flow started (W0-1, D1), so an + * authorization response WITHOUT `iss` must be rejected. */ + issRequired: boolean; } | undefined > { @@ -1523,6 +1625,7 @@ export class AgentGraphStore { scopes: string | null; token_endpoint: string | null; authorization_endpoint: string | null; + iss_required?: boolean | null; }>( "DELETE FROM mcp_oauth_flows WHERE state = $1 AND created_at > now() - interval '15 minutes' RETURNING *", [state], @@ -1539,6 +1642,9 @@ export class AgentGraphStore { scopes: r.scopes, tokenEndpoint: r.token_endpoint, authorizationEndpoint: r.authorization_endpoint, + // Pre-0031 in-flight flows read false — they are not retroactively + // rejected for a missing `iss` (a MISMATCHED one still is). + issRequired: r.iss_required === true, } : undefined; } @@ -1714,11 +1820,15 @@ export class AgentGraphStore { readonly error: string | null; readonly durationMs: number; readonly calledAt: Date; + /** W0-1 — the resolved acting identity. Always written (never omitted): + * an audit row with no identity cannot answer "whose credentials was + * this?", which is the whole point of the confused-deputy fix. */ + readonly actingIdentity: string | null; }): Promise { await this.pool.query( `INSERT INTO mcp_call_log - (server_id, server_name, tool_name, caller_kind, caller_agent, turn_id, ok, error, duration_ms, called_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + (server_id, server_name, tool_name, caller_kind, caller_agent, turn_id, ok, error, duration_ms, called_at, acting_identity) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, [ entry.serverId, entry.serverName, @@ -1730,6 +1840,7 @@ export class AgentGraphStore { entry.error, entry.durationMs, entry.calledAt, + entry.actingIdentity, ], ); } @@ -1770,6 +1881,7 @@ export class AgentGraphStore { error: r.error, durationMs: r.duration_ms, calledAt: r.called_at, + actingIdentity: r.acting_identity ?? null, })); } @@ -2148,15 +2260,28 @@ export class AgentGraphStore { } } + /** + * Persist the discovered-tool descriptors verbatim. `discovered_tools` is a + * `jsonb` column and the descriptor is stored whole, so every field a + * `McpToolDescriptor` carries round-trips — including the `outputSchema` + * added in issue #547 (W1-3). No migration is needed to add descriptor + * fields; only the TypeScript shape changes. + */ async setMcpDiscoveredTools( id: string, tools: readonly unknown[], ): Promise { + // W0-3 — normalize by name before persisting. An MCP server may return + // `tools/list` in a different order on every call; storing that raw makes + // each rediscovery rewrite the JSONB with semantically identical content, + // churning the row and any grant-epoch diff computed from it. It also + // leaks the server's arbitrary ordering into the tool block that + // `subAgentToolHydration` later builds from this column. await this.pool.query( `UPDATE mcp_servers SET discovered_tools = $2::jsonb, last_discovered_at = now(), updated_at = now() WHERE id = $1`, - [id, JSON.stringify(tools)], + [id, JSON.stringify(normalizeDiscoveredToolOrder(tools))], ); } diff --git a/middleware/packages/harness-orchestrator/src/registry/index.ts b/middleware/packages/harness-orchestrator/src/registry/index.ts index f97e9ec7..00650a2a 100644 --- a/middleware/packages/harness-orchestrator/src/registry/index.ts +++ b/middleware/packages/harness-orchestrator/src/registry/index.ts @@ -624,7 +624,7 @@ export function validateSnapshot( const seenBindings = new Set(); for (const binding of snap.channelBindings) { - const key = `${binding.channelType}${binding.channelKey}`; + const key = `${binding.channelType}\0${binding.channelKey}`; if (seenBindings.has(key)) { throw new ConfigValidationError( `duplicate channel binding (${binding.channelType}, ${binding.channelKey})`, diff --git a/middleware/packages/harness-orchestrator/src/registry/subAgentTools.ts b/middleware/packages/harness-orchestrator/src/registry/subAgentTools.ts index 67c9a416..841c0f45 100644 --- a/middleware/packages/harness-orchestrator/src/registry/subAgentTools.ts +++ b/middleware/packages/harness-orchestrator/src/registry/subAgentTools.ts @@ -4,6 +4,7 @@ import type { LocalSubAgentTool } from '@omadia/plugin-api'; import { createCliSubAgent } from '../cliSubAgent.js'; import { LocalSubAgent } from '../localSubAgent.js'; import { resolveModelIdForProvider } from './agentRuntime.js'; +import { sortBySpecName } from '../toolOrdering.js'; import type { McpManager} from '../mcp/mcpClient.js'; import { @@ -176,7 +177,11 @@ export function resolveCliSubAgentModel( ); } -function resolveSubAgentTools( +/** + * Exported for the W0-3 determinism test; production callers should go + * through `buildSubAgentDomainTools`. + */ +export function resolveSubAgentTools( grants: readonly ToolGrantRow[], deps: SubAgentToolDeps, ): LocalSubAgentTool[] { @@ -209,7 +214,11 @@ function resolveSubAgentTools( mcpToolToLocalSubAgentTool(deps.mcpManager, cfg, { name: toolName }), ); } - return out; + // W0-3 — sort by name. Grants arrive in `created_at` row order, so two + // machines that were seeded at different times would hand the sub-agent an + // identical tool SET in a different sequence, defeating prompt caching on + // the sub-agent's own tool block for no behavioural gain. + return sortBySpecName(out); } /** `toolRef` for an mcp grant is ":"; fall back to the diff --git a/middleware/packages/harness-orchestrator/src/tasks/inMemoryTaskStore.ts b/middleware/packages/harness-orchestrator/src/tasks/inMemoryTaskStore.ts new file mode 100644 index 00000000..866ea350 --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/tasks/inMemoryTaskStore.ts @@ -0,0 +1,387 @@ +/** + * W2-2 — process-local {@link TaskStore}. + * + * The reference implementor of the seam's claim/lease semantics, and the + * backing store for consumers that have no table of their own yet. + * + * ## Why in-memory, deliberately + * + * W2-2 is a seam EXTRACTION with no schema change: migrations `0031`/`0032` are + * taken by parallel units, and adding a `tasks` table here would collide. A + * durable second implementor is the follow-up. The consequence is honest and + * bounded: tasks held here do not survive a restart, so a restart is treated + * exactly like a crashed worker — the state is simply gone and the model's + * `_status` poll answers "not found". A Postgres-backed implementor is + * unaffected. + * + * ## Atomicity + * + * Node runs one JS task at a time, so `claimNextPending`'s read-then-stamp is + * atomic by construction: nothing can interleave between picking the candidate + * and writing its lease because there is no `await` between them. That is the + * same guarantee `FOR UPDATE SKIP LOCKED` buys the Postgres implementor, for a + * single process. It does NOT hold across processes — which is precisely why the + * lease fence below still exists and still throws. + */ + +import { randomUUID } from 'node:crypto'; + +import { + TASK_LEASE_UUID_RE, + TaskLeaseLostError, + isTerminalTaskStatus, + type NewTaskInput, + type TaskDescriptor, + type TaskEventRecord, + type TaskListFilter, + type TaskReapOptions, + type TaskReapResult, + type TaskStore, + type TerminalTaskPatch, +} from './taskTypes.js'; + +/** Per-task event cap. The tail is for "what is it doing", not an audit log, + * so old lines are dropped. */ +const DEFAULT_MAX_EVENTS = 200; + +/** + * Soft ceiling on retained tasks. NOT a hard bound, and calling it one was + * wrong: `evictIfOverCapacity` only ever drops TERMINAL rows (evicting a live + * task would strand its worker and make the model's next poll say "not found" + * about work that is still running). So the map is bounded at this size only + * while terminal rows are available to sacrifice — with N live tasks and no + * terminal ones it grows to N regardless. The real bound on live rows is the + * orphan sweep, which turns them terminal; this constant bounds the debris the + * sweep has not yet purged. Oldest terminal tasks evict first. + */ +const DEFAULT_MAX_TASKS = 500; + +interface TaskRow { + descriptor: TaskDescriptor; + input: unknown; + createdBy: string | null; + events: TaskEventRecord[]; + nextSeq: number; +} + +export interface InMemoryTaskStoreOptions { + readonly maxEvents?: number; + readonly maxTasks?: number; + /** Injected clock. Defaults to `Date.now`. */ + readonly clock?: () => number; +} + +export class InMemoryTaskStore implements TaskStore { + private readonly rows = new Map(); + private readonly maxEvents: number; + private readonly maxTasks: number; + private readonly clock: () => number; + + constructor(opts: InMemoryTaskStoreOptions = {}) { + this.maxEvents = opts.maxEvents ?? DEFAULT_MAX_EVENTS; + this.maxTasks = opts.maxTasks ?? DEFAULT_MAX_TASKS; + this.clock = opts.clock ?? ((): number => Date.now()); + } + + private nowIso(): string { + return new Date(this.clock()).toISOString(); + } + + /** + * Resolve a row for a lease-fenced write. Throws {@link TaskLeaseLostError} + * when the task is gone, already terminal, or owned by a different lease — + * the three cases that must all read as "you no longer own this". + */ + private fenced(id: string, lease: string): TaskRow { + const row = this.rows.get(id); + if (!row) throw new TaskLeaseLostError(id); + // Terminal immutability. Load-bearing, NOT redundant with the lease check + // below: `reapOrphans` deliberately does NOT clear `claimedBy` when it fails + // an abandoned task, so a zombie worker that wakes up afterwards still + // presents a MATCHING lease. This guard is the only thing stopping it from + // resurrecting or overwriting an outcome the reaper already recorded. + if (isTerminalTaskStatus(row.descriptor.status)) { + throw new TaskLeaseLostError(id); + } + if (row.descriptor.claimedBy !== lease) throw new TaskLeaseLostError(id); + return row; + } + + async create(input: NewTaskInput): Promise { + this.evictIfOverCapacity(); + const ts = this.nowIso(); + const descriptor: TaskDescriptor = { + id: randomUUID(), + kind: input.kind, + status: 'working', + phase: input.phase ?? 'queued', + createdAt: ts, + updatedAt: ts, + endedAt: null, + claimedBy: null, + lastHeartbeatAt: null, + result: null, + error: null, + }; + this.rows.set(descriptor.id, { + descriptor, + input: input.input, + createdBy: input.createdBy ?? null, + events: [], + nextSeq: 1, + }); + return descriptor; + } + + async get(id: string): Promise { + return this.rows.get(id)?.descriptor ?? null; + } + + async list(filter: TaskListFilter = {}): Promise { + const limit = Math.min(Math.max(1, Math.trunc(filter.limit ?? 50)), 500); + const out: TaskDescriptor[] = []; + for (const row of this.rows.values()) { + if (filter.kind !== undefined && row.descriptor.kind !== filter.kind) continue; + if (filter.status !== undefined && row.descriptor.status !== filter.status) { + continue; + } + if (filter.createdBy !== undefined && row.createdBy !== filter.createdBy) { + continue; + } + out.push(row.descriptor); + } + // Newest first — the contract's list order, equivalent to a SQL-backed + // implementor's `ORDER BY created_at DESC`. + out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0)); + return out.slice(0, limit); + } + + async eventTail(id: string, limit: number): Promise { + const row = this.rows.get(id); + if (!row) return []; + const n = Math.min(Math.max(1, Math.trunc(limit)), this.maxEvents); + return row.events.slice(-n); + } + + async claimNextPending( + lease: string, + kind?: string, + taskId?: string, + ): Promise<{ descriptor: TaskDescriptor; input: unknown } | null> { + if (!TASK_LEASE_UUID_RE.test(lease)) { + throw new TypeError(`claimNextPending: lease must be a UUID (got '${lease}')`); + } + // `taskId` narrows the candidate set to exactly one row, which is what makes + // a per-task runner claim ITS OWN task instead of the pool head. Without it + // two runners started for two same-kind tasks cross their claims (each takes + // the other's) — and the crossed pair used to be dropped unfinished under + // live leases until the reaper failed them 15 minutes later. + // Oldest-first, mirroring `claimNextQueued`'s `ORDER BY created_at LIMIT 1`. + let candidate: TaskRow | undefined; + for (const row of this.rows.values()) { + const d = row.descriptor; + if (d.status !== 'working' || d.claimedBy !== null) continue; + if (kind !== undefined && d.kind !== kind) continue; + if (taskId !== undefined && d.id !== taskId) continue; + if (!candidate || d.createdAt < candidate.descriptor.createdAt) candidate = row; + } + if (!candidate) return null; + // No `await` between the scan above and this write — the claim is atomic + // within this process, which is what makes double-claiming impossible here. + const ts = this.nowIso(); + candidate.descriptor = { + ...candidate.descriptor, + claimedBy: lease, + lastHeartbeatAt: ts, + updatedAt: ts, + }; + return { descriptor: candidate.descriptor, input: candidate.input }; + } + + async heartbeat(id: string, lease: string): Promise { + const row = this.fenced(id, lease); + const ts = this.nowIso(); + row.descriptor = { ...row.descriptor, lastHeartbeatAt: ts, updatedAt: ts }; + } + + async setPhase(id: string, lease: string, phase: string): Promise { + const row = this.fenced(id, lease); + row.descriptor = { ...row.descriptor, phase, updatedAt: this.nowIso() }; + } + + async appendEvents( + id: string, + lease: string, + events: readonly { type: string; message: string }[], + ): Promise { + const row = this.fenced(id, lease); + if (events.length === 0) return; + const ts = this.nowIso(); + for (const ev of events) { + row.events.push({ seq: row.nextSeq, ts, type: ev.type, message: ev.message }); + row.nextSeq += 1; + } + if (row.events.length > this.maxEvents) { + row.events.splice(0, row.events.length - this.maxEvents); + } + row.descriptor = { ...row.descriptor, lastHeartbeatAt: ts, updatedAt: ts }; + } + + async finish( + id: string, + lease: string, + patch: TerminalTaskPatch, + ): Promise { + const row = this.fenced(id, lease); + const ts = this.nowIso(); + row.descriptor = { + ...row.descriptor, + status: patch.status, + ...(patch.phase !== undefined ? { phase: patch.phase } : {}), + result: patch.result ?? null, + error: patch.error ?? null, + endedAt: ts, + updatedAt: ts, + claimedBy: null, + }; + return row.descriptor; + } + + async requireInput( + id: string, + lease: string, + phase?: string, + ): Promise { + const row = this.fenced(id, lease); + const ts = this.nowIso(); + // The lease is RELEASED: the task is now waiting on a human, not on this + // worker, so the claim loop must be free to re-claim it once unblocked. + row.descriptor = { + ...row.descriptor, + status: 'input_required', + ...(phase !== undefined ? { phase } : {}), + claimedBy: null, + updatedAt: ts, + }; + return row.descriptor; + } + + /** + * The one deliberately UNFENCED writer on this store. + * + * Every other write goes through {@link fenced} and needs the owning lease. + * This one does not, and cannot: the whole premise of an orphan sweep is that + * the lease holder is gone, so demanding its lease would make the sweep + * unable to do its job. It is the administrative exception the `TaskStore` + * doc calls out, not a hole in the fence — and it is why terminal + * immutability in `fenced()` is load-bearing rather than redundant: it is what + * a zombie worker (which still presents a MATCHING lease, because the sweep + * preserves `claimedBy`) runs into afterwards. + */ + async reapOrphans(opts: TaskReapOptions): Promise { + if (!Number.isFinite(opts.staleAfterMs) || opts.staleAfterMs <= 0) { + throw new TypeError('reapOrphans: staleAfterMs must be a positive number'); + } + if ( + !Number.isFinite(opts.purgeTerminalAfterMs) || + opts.purgeTerminalAfterMs <= 0 + ) { + throw new TypeError( + 'reapOrphans: purgeTerminalAfterMs must be a positive number', + ); + } + if ( + opts.parkedStaleAfterMs !== undefined && + (!Number.isFinite(opts.parkedStaleAfterMs) || opts.parkedStaleAfterMs <= 0) + ) { + throw new TypeError( + 'reapOrphans: parkedStaleAfterMs, when given, must be a positive number', + ); + } + const nowMs = (opts.now ?? new Date(this.clock())).getTime(); + let staleFailed = 0; + let purged = 0; + + for (const [id, row] of [...this.rows.entries()]) { + const d = row.descriptor; + if (isTerminalTaskStatus(d.status)) { + const endedMs = d.endedAt ? Date.parse(d.endedAt) : Date.parse(d.updatedAt); + if (Number.isFinite(endedMs) && nowMs - endedMs >= opts.purgeTerminalAfterMs) { + this.rows.delete(id); + purged += 1; + } + continue; + } + // A PARKED task is waiting on a human, not on a worker. `requireInput` + // released the lease and froze `lastHeartbeatAt`, and nothing heartbeats a + // parked row — so judging it by the worker-liveness window force-failed + // every card a user took longer than 15 minutes to answer, and the answer + // then landed on a task already marked `failed`. It gets its own explicit + // window, measured from when it parked, and by default no window at all. + // An implementor whose stall sweep already excludes its own gate state + // never had this bug; the generic sweep has to exclude it explicitly. + const parked = d.status === 'input_required'; + if (parked && opts.parkedStaleAfterMs === undefined) continue; + // Live task. "Last sign of life" is the heartbeat when a worker ever + // claimed it, else creation — so a task that was NEVER claimed (no worker + // running, the classic orphan) is reaped too, instead of leaking forever. + // A parked task instead ages from `updatedAt`, the moment it parked. + const lastSeen = parked + ? Date.parse(d.updatedAt) + : Date.parse(d.lastHeartbeatAt ?? d.createdAt); + const window = parked + ? (opts.parkedStaleAfterMs as number) + : opts.staleAfterMs; + if (Number.isFinite(lastSeen) && nowMs - lastSeen >= window) { + const ts = new Date(nowMs).toISOString(); + // `claimedBy` is deliberately PRESERVED. The reaper is not the owner: + // keeping the lease records who was working when the task died, and — + // more importantly — it means a zombie worker waking up later still + // presents a matching lease, so the terminal guard in `fenced()` is what + // rejects it. Clearing the lease here would make that guard unreachable + // and let the lease check alone silently carry the invariant. + row.descriptor = { + ...d, + status: 'failed', + error: parked + ? 'task expired: no human answered the input request within the ' + + 'parked-task window' + : 'task abandoned: no worker heartbeat within the orphan window ' + + '(worker crashed, restarted, or was never started)', + endedAt: ts, + updatedAt: ts, + }; + staleFailed += 1; + } + } + return { staleFailed, purged }; + } + + /** Test/introspection helper: how many rows are retained right now. */ + size(): number { + return this.rows.size; + } + + /** + * Push back on growth even with no reaper scheduled: drop the oldest TERMINAL + * rows, and never evict a live task (losing a live handle would strand the + * worker and lie to the model on its next poll). + * + * Consequence, stated rather than implied: when there is nothing terminal to + * drop this is a no-op and `maxTasks` is exceeded. See + * {@link DEFAULT_MAX_TASKS}. + */ + private evictIfOverCapacity(): void { + if (this.rows.size < this.maxTasks) return; + const terminal = [...this.rows.entries()] + .filter(([, r]) => isTerminalTaskStatus(r.descriptor.status)) + .sort((a, b) => + a[1].descriptor.createdAt < b[1].descriptor.createdAt ? -1 : 1, + ); + const target = this.rows.size - this.maxTasks + 1; + for (let i = 0; i < Math.min(target, terminal.length); i += 1) { + const entry = terminal[i]; + if (entry) this.rows.delete(entry[0]); + } + } +} diff --git a/middleware/packages/harness-orchestrator/src/tasks/longRunningTool.ts b/middleware/packages/harness-orchestrator/src/tasks/longRunningTool.ts new file mode 100644 index 00000000..1a326fa5 --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/tasks/longRunningTool.ts @@ -0,0 +1,579 @@ +/** + * W2-2 — the generic registration helper: mark any tool `longRunning` and get + * the non-blocking `_start` / `_status` / `_list` triple plus + * a streaming status card, for free. + * + * Generalized from the first tool that needed it, which hand-rolled exactly this + * `_start` / `_status` / `_list` shape for itself. + * + * ## The contract the model sees + * + * `_start` → returns AT ONCE: + * `{"status":"task_started","taskId":…,"tool":…,"kind":…,"phase":"queued"}` + * `_status` → `{taskId, status, phase, result?, error?, recentEvents:[…]}` + * `_list` → `[{taskId, kind, status, phase}, …]` + * + * The model is told, in the prompt doc, that `_start` does NOT answer the + * question — it hands back a handle. The turn ends with "started, I'll report + * back". That is the accepted UX, and it is strictly better than the + * alternative: a chat turn has no park/resume (`chat.ts` streams SSE with a + * heartbeat and ends when the model loop ends), so holding the stream open for + * minutes just trades a clean handoff for proxy idle timeouts, Teams activity + * expiry, and reaped connections. + * + * ## Interaction with the per-tool dispatch deadline + * + * A separate unit adds `OMADIA_TOOL_DISPATCH_TIMEOUT_MS` (default 240 s) around + * tool dispatch. It does not interact with this path in any harmful way, BY + * CONSTRUCTION: every handler here is bounded by a store round-trip, so + * `_start` returns in milliseconds and can never approach that deadline. The + * long work runs in a DETACHED runner (see `startRunner`) that is not inside the + * dispatch call at all, so the deadline has nothing to cancel. That is the point + * of the seam — a deadline and a long-running tool stop being in conflict once + * the tool stops blocking the turn. + * + * ## Deferred-result privacy — see `describeDeferredPrivacyPosture()` + * + * This is the one genuinely open problem; it is documented rather than papered + * over. Read that function's doc comment before changing anything here. + */ + +import { randomUUID } from 'node:crypto'; + +import type { NativeToolHandler, NativeToolSpec } from '@omadia/plugin-api'; + +import { + TaskLeaseLostError, + isTerminalTaskStatus, + type TaskCardPayload, + type TaskDescriptor, + type TaskEventRecord, + type TaskLifecycleStatus, + type TaskStore, + type TerminalTaskPatch, +} from './taskTypes.js'; + +// --------------------------------------------------------------------------- +// Registration shape. Structurally identical to the hand-rolled +// `KernelToolRegistration` shapes elsewhere (e.g. +// `plugins/selfExtension/requestSelfExtensionTool.ts`), so boot registers these +// through the very same `nativeToolRegistry.register(name, {…})` call. +// --------------------------------------------------------------------------- + +export interface LongRunningToolRegistration { + readonly name: string; + readonly spec: NativeToolSpec; + readonly promptDoc: string; + readonly handler: NativeToolHandler; +} + +/** How many event lines `_status` returns. */ +const STATUS_EVENT_TAIL = 5; + +// --------------------------------------------------------------------------- +// Definition — what a consumer supplies. +// --------------------------------------------------------------------------- + +/** + * What the detached runner does. Receives the validated input and a + * {@link TaskExecutionHandle} for progress reporting; resolves with the + * model-facing result string, or throws to fail the task. + */ +export type TaskExecutor = ( + input: unknown, + handle: TaskExecutionHandle, +) => Promise; + +/** The lease-bound progress surface handed to a {@link TaskExecutor}. */ +export interface TaskExecutionHandle { + readonly taskId: string; + /** Lease-fenced progress label update. */ + setPhase(phase: string): Promise; + /** Lease-fenced event append (also bumps the heartbeat). */ + log(type: string, message: string): Promise; + /** Lease-fenced liveness touch, for long silent stretches. */ + heartbeat(): Promise; +} + +export interface LongRunningToolDefinition { + /** + * Base tool name — the triple becomes `_start`, `_status`, + * `_list`. Must satisfy Anthropic's tool-name charset and leave room + * for the longest suffix (`_status`, 7 chars) inside the 64-char limit. + */ + readonly toolName: string; + /** Marks this definition as opting into the non-blocking path. */ + readonly longRunning: true; + /** Implementor-defined subtype recorded on every task. */ + readonly kind: string; + /** What `_start` does, for the model's tool list. */ + readonly startDescription: string; + /** JSON-schema properties for `_start`'s input. */ + readonly inputProperties: Record; + readonly requiredInput?: readonly string[]; + /** Optional pre-dispatch validation. Return a string to REFUSE with it. */ + readonly validateInput?: (input: unknown) => string | null; + /** Short non-sensitive card label (e.g. the sub-agent's display name). */ + readonly cardLabel: string; + /** Where the card polls, if the consumer exposes an SSE endpoint. */ + readonly eventsUrlFor?: (taskId: string) => string; + readonly store: TaskStore; + readonly execute: TaskExecutor; + /** Injected for tests; defaults to `console.warn`. */ + readonly onRunnerError?: (err: unknown, taskId: string) => void; + /** + * Called when a runner produced a real outcome it could NOT record, because + * the lease was already gone (almost always: the orphan reaper decided the + * worker was dead and wrote its own terminal row first). See + * {@link TaskOutcomeLostRecord} — the outcome is real and the store's row is + * not, so this is the only place it survives. + * + * Defaults to a metadata-only `console.warn`. The default deliberately does + * NOT print `result`: a task result may carry PII (see + * {@link describeDeferredPrivacyPosture}) and the log is outside the Privacy + * Shield data-plane boundary. A consumer that wants to persist the payload + * opts in by supplying its own handler and taking on that obligation. + */ + readonly onOutcomeLost?: (record: TaskOutcomeLostRecord) => void; +} + +/** + * A terminal outcome a runner produced but could not write: `store.finish` + * rejected with {@link TaskLeaseLostError}. + * + * The row the model will poll says something else — for a reaped task, the + * reaper's generic "task abandoned". That row cannot be corrected: terminal + * immutability in the store is load-bearing (it is what stops a zombie worker + * from overwriting an outcome a NEW owner recorded), and relaxing it for this + * case would relax it for that one too. So the honest move is to surface the + * real outcome here instead of letting it evaporate inside a `catch`. + */ +export interface TaskOutcomeLostRecord { + readonly taskId: string; + /** The lease the runner still believed it held. */ + readonly lease: string; + /** What the runner actually concluded. */ + readonly status: 'completed' | 'failed'; + /** Present when `status === 'completed'`. May contain PII — see above. */ + readonly result?: string; + /** Present when `status === 'failed'`. */ + readonly error?: string; +} + +export interface LongRunningToolHandle { + readonly registrations: readonly LongRunningToolRegistration[]; + /** + * Returns and CLEARS the cards queued by `_start` calls this turn. + * Accumulates (a turn may start more than one task) and does NOT + * short-circuit the turn. + */ + takePendingCards(): readonly TaskCardPayload[]; + hasPendingCards(): boolean; + /** + * Await the in-flight detached runners. TEST-ONLY: production never calls + * this — the whole point is that the turn does not wait. + */ + drainForTest(): Promise; +} + +// --------------------------------------------------------------------------- +// Deferred-result privacy. +// --------------------------------------------------------------------------- + +/** + * ### Deferred-result privacy posture (criterion 6) — READ BEFORE CHANGING + * + * `Orchestrator.dispatchTool` maintains `subAgentDatasetSink`, + * `subAgentBypassFlag`, and `subAgentOwnerPluginId` inside an + * `AsyncLocalStorage` scope (`turnContext.run`). A detached runner outlives the + * turn, so by the time it finishes that scope is gone — there is no sink to + * intern into and no bypass flag to record against. + * + * SOLVED, for the path that matters. A task's `result` never reaches the model + * out-of-band. It reaches the model ONLY as the return value of the + * `_status` handler, which is an ordinary tool call inside a LIVE, + * LATER turn — so that turn's `dispatchTool` privacy pass applies to it in full, + * with a live sink and a live bypass flag. Interning happens at POLL time + * instead of at completion time, and the data-plane boundary holds. + * + * Two invariants make that true, and both are enforced by test: + * + * 1. {@link TaskCardPayload} carries NO result and NO input. Cards are rendered + * from the tool result / stream event and never pass through + * `dispatchTool`, so anything on a card escapes the boundary. Cards carry + * ids, the projected status, the progress label, and a poll URL — nothing + * more. + * 2. There is no push channel from a finished task into a conversation. The + * model must poll. Adding one later WOULD reintroduce the hole and must come + * with its own interning path. + * + * NOT SOLVED — accepted v1 limitations, to fix with the durable second + * implementor: + * + * a. Bypass ATTRIBUTION for work done inside the detached runner is lost. If + * the executor's own inner tool calls honour an operator `bypass` setting, + * that fact cannot be recorded against the originating turn, because the + * turn is over and `privacy.recordBypassedTool` has already run for it. The + * data still does not leak (see above) — what is missing is the audit line + * saying "this turn's task bypassed the shield". + * b. `subAgentOwnerPluginId` is not available to the runner, so a deferred + * sub-agent's inner calls resolve bypass WITHOUT the owning plugin's + * `_privacy_mode`. The safe direction: absent the plugin id the resolver + * falls back to NOT bypassing, i.e. the deferred path is stricter than the + * inline one, never looser. + * + * Returned as a string so the posture is greppable from a test and cannot drift + * silently away from this comment. + */ +export function describeDeferredPrivacyPosture(): string { + return ( + 'deferred-task results are interned at POLL time by the _status handler ' + + "inside a live turn's dispatchTool scope; cards carry no result or input; " + + 'bypass attribution for the detached runner is a documented v1 limitation' + ); +} + +// --------------------------------------------------------------------------- +// Helpers. +// --------------------------------------------------------------------------- + +function errString(prefix: string, err: unknown): string { + const msg = err instanceof Error ? err.message : String(err); + return `Error: ${prefix}: ${msg}`; +} + +/** Compact, model-facing descriptor view (keeps the tool return small). */ +function compact(d: TaskDescriptor): Record { + return { + taskId: d.id, + kind: d.kind, + status: d.status, + phase: d.phase, + ...(d.result !== null ? { result: d.result } : {}), + ...(d.error !== null ? { error: d.error } : {}), + }; +} + +function compactEvent(e: TaskEventRecord): Record { + return { seq: e.seq, ts: e.ts, type: e.type, message: e.message }; +} + +/** `_start` etc., rejecting a base that cannot fit the suffixes. */ +export function longRunningToolNames(base: string): { + start: string; + status: string; + list: string; +} { + if (!/^[a-zA-Z0-9_-]+$/.test(base)) { + throw new TypeError( + `longRunningTool: toolName '${base}' must match [a-zA-Z0-9_-]+`, + ); + } + // 64 is Anthropic's tool-name limit; `_status` is the longest suffix. + if (base.length + '_status'.length > 64) { + throw new TypeError( + `longRunningTool: toolName '${base}' is too long for the _status suffix`, + ); + } + return { start: `${base}_start`, status: `${base}_status`, list: `${base}_list` }; +} + +// --------------------------------------------------------------------------- +// The factory. +// --------------------------------------------------------------------------- + +/** + * Turn one long-running operation into the non-blocking tool triple. + * + * The returned registrations go to `nativeToolRegistry.register(name, {handler, + * spec, promptDoc})` exactly like any hand-rolled native tool's do, and + * `takePendingCards()` feeds the chat card stream. + */ +export function defineLongRunningTool( + def: LongRunningToolDefinition, +): LongRunningToolHandle { + const names = longRunningToolNames(def.toolName); + const pendingCards: TaskCardPayload[] = []; + const inFlight = new Set>(); + const onRunnerError = + def.onRunnerError ?? + ((err: unknown, taskId: string): void => { + console.warn( + `[longRunningTool:${def.toolName}] detached runner for task ${taskId} threw:`, + err, + ); + }); + const onOutcomeLost = + def.onOutcomeLost ?? + ((record: TaskOutcomeLostRecord): void => { + // Metadata only — never the payload. See `onOutcomeLost`'s doc comment. + console.warn( + `[longRunningTool:${def.toolName}] task ${record.taskId} finished as ` + + `'${record.status}' but its lease was already gone, so the outcome could ` + + `not be recorded — the stored row (most likely the reaper's "task ` + + `abandoned") does not reflect it. Payload withheld from the log.`, + ); + }); + + /** + * Record a terminal outcome, or — when the lease is gone — surface it. + * + * The failure this exists for: `finish(…, 'completed')` throws + * {@link TaskLeaseLostError} because the reaper already wrote a terminal row. + * The old code caught that in the generic executor `catch` and called + * `finish(…, 'failed')` on the now-terminal row, which threw AGAIN and escaped + * to `onRunnerError` — so a task that genuinely SUCCEEDED was narrated to the + * caller as the reaper's generic "task abandoned", and the real result was + * dropped on the floor with no trace. + * + * Lease loss is a legitimate terminal condition for a runner, not a runner + * error: it means someone else owns the outcome now. It is reported through + * {@link LongRunningToolDefinition.onOutcomeLost} and never re-thrown. Any + * OTHER store failure still propagates to `onRunnerError`, which is what that + * hook is for. + */ + async function finishOrReportLoss( + taskId: string, + lease: string, + patch: TerminalTaskPatch, + ): Promise { + try { + await def.store.finish(taskId, lease, patch); + } catch (err: unknown) { + if (!(err instanceof TaskLeaseLostError)) throw err; + onOutcomeLost({ + taskId, + lease, + status: patch.status, + ...(patch.result !== undefined ? { result: patch.result } : {}), + ...(patch.error !== undefined ? { error: patch.error } : {}), + }); + } + } + + /** + * Claim the freshly created task and execute it, DETACHED from the turn. + * + * Not awaited by `_start` — that is the entire non-blocking property. Errors + * are funnelled into the task's terminal `failed` state so a poll always gets + * an answer; an error while RECORDING the failure is the only thing that + * reaches `onRunnerError`. + * + * ## Never strand a claim (crossed-claim fix) + * + * `claimNextPending` used to be called WITHOUT the task id, so it handed back + * the pool head — the oldest unclaimed task of this kind, not necessarily the + * one this runner was spawned for. Two `_start` calls in one turn therefore + * crossed: B's runner claimed A, saw the id mismatch and returned *without + * releasing the claim*, while A's runner did the same to B. Both tasks sat + * `working` under live-but-dead leases with no executor until the reaper + * force-failed them 15 minutes later. + * + * Two things fix it, and both are needed: + * 1. the task id is passed as a claim hint, so a store that can honour it + * (`InMemoryTaskStore`) claims exactly this task or nothing; and + * 2. whatever comes back is treated as AUTHORITATIVE. A store that cannot + * honour the hint — one whose claim is a bare pool pop with no release + * primitive — still gets its claim followed through to a terminal state, + * because a claim this runner cannot hand back is a claim it must finish. + */ + function startRunner(taskId: string): void { + const lease = randomUUID(); + const run = (async (): Promise => { + const claimed = await def.store.claimNextPending(lease, def.kind, taskId); + // Nothing claimable: someone else owns it (another process), or the + // reaper already failed it. No claim was taken, so there is nothing to + // release — the owner (or the reaper) finishes it. + if (!claimed) return; + // Authoritative id. Normally === taskId; differs only for a store that + // ignores the hint, and then THIS is the task we hold the lease on. + const claimedId = claimed.descriptor.id; + if (claimedId !== taskId) { + console.warn( + `[longRunningTool:${def.toolName}] runner for task ${taskId} was handed ` + + `task ${claimedId} by a store that cannot honour the claim hint — ` + + `executing the claimed task rather than stranding it.`, + ); + } + const handle: TaskExecutionHandle = { + taskId: claimedId, + setPhase: (phase) => def.store.setPhase(claimedId, lease, phase), + log: (type, message) => + def.store.appendEvents(claimedId, lease, [{ type, message }]), + heartbeat: () => def.store.heartbeat(claimedId, lease), + }; + let result: string; + try { + result = await def.execute(claimed.input, handle); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + await finishOrReportLoss(claimedId, lease, { status: 'failed', error: message }); + return; + } + await finishOrReportLoss(claimedId, lease, { status: 'completed', result }); + })().catch((err: unknown) => { + onRunnerError(err, taskId); + }); + inFlight.add(run); + void run.finally(() => inFlight.delete(run)); + } + + const startSpec: NativeToolSpec = { + name: names.start, + description: def.startDescription, + input_schema: { + type: 'object', + properties: def.inputProperties, + ...(def.requiredInput ? { required: [...def.requiredInput] } : {}), + }, + }; + + const statusSpec: NativeToolSpec = { + name: names.status, + description: + `Look up the current status of a task started with \`${names.start}\`: ` + + 'its lifecycle status (working | input_required | completed | failed), ' + + 'progress label, the last few event lines, and — once completed — the ' + + 'result. Returns "Error: …" if the task id is unknown.', + input_schema: { + type: 'object', + properties: { taskId: { type: 'string', description: 'The task id.' } }, + required: ['taskId'], + }, + }; + + const listSpec: NativeToolSpec = { + name: names.list, + description: + `List tasks started with \`${names.start}\`, optionally filtered by ` + + 'lifecycle status.', + input_schema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['working', 'input_required', 'completed', 'failed'], + description: 'Optional — restrict to one lifecycle status.', + }, + }, + }, + }; + + const startPromptDoc = + `${names.start}: begin a long-running operation. It returns IMMEDIATELY ` + + `with a task id — it does NOT return the answer. Tell the user the work ` + + `has started and that you will report back; then use ${names.status} ` + + `(later, or in a following turn) to fetch the outcome. Never wait in a ` + + `loop for it inside one turn.`; + + const statusPromptDoc = + `${names.status}: fetch a task's current status, recent events, and — once ` + + `it is \`completed\` — its result. Use it to answer "how is that going?" ` + + `and to collect the deferred answer.`; + + const listPromptDoc = `${names.list}: list the tasks this tool has started.`; + + /** + * `_start` — validate, create, queue a card, kick off the detached + * runner, return the handle. Never throws; refusals come back as + * `Error: …` strings (the orchestrator contract). + */ + const handleStart: NativeToolHandler = async (raw: unknown) => { + if (typeof raw !== 'object' || raw === null) { + return `Error: invalid ${names.start} input — expected an object.`; + } + const refusal = def.validateInput?.(raw); + if (refusal !== undefined && refusal !== null) { + return `Error: ${refusal}`; + } + try { + const descriptor = await def.store.create({ kind: def.kind, input: raw }); + // Card = non-sensitive metadata ONLY. See describeDeferredPrivacyPosture. + pendingCards.push({ + taskId: descriptor.id, + toolName: names.start, + kind: descriptor.kind, + status: descriptor.status, + phase: descriptor.phase, + label: def.cardLabel, + eventsUrl: def.eventsUrlFor?.(descriptor.id) ?? null, + }); + startRunner(descriptor.id); + return JSON.stringify({ + status: 'task_started', + taskId: descriptor.id, + tool: names.start, + kind: descriptor.kind, + phase: descriptor.phase, + }); + } catch (err: unknown) { + return errString(`${names.start} failed`, err); + } + }; + + const handleStatus: NativeToolHandler = async (raw: unknown) => { + const taskId = + typeof raw === 'object' && raw !== null + ? (raw as Record)['taskId'] + : undefined; + if (typeof taskId !== 'string' || taskId.length === 0) { + return `Error: invalid ${names.status} input — \`taskId\` must be a non-empty string.`; + } + try { + const descriptor = await def.store.get(taskId); + if (!descriptor) { + return `Error: task "${taskId}" was not found or is no longer retained.`; + } + const events = await def.store.eventTail(taskId, STATUS_EVENT_TAIL); + return JSON.stringify({ + ...compact(descriptor), + terminal: isTerminalTaskStatus(descriptor.status), + recentEvents: events.map(compactEvent), + }); + } catch (err: unknown) { + return errString(`${names.status} failed`, err); + } + }; + + const handleList: NativeToolHandler = async (raw: unknown) => { + const statusRaw = + typeof raw === 'object' && raw !== null + ? (raw as Record)['status'] + : undefined; + if (statusRaw !== undefined && typeof statusRaw !== 'string') { + return `Error: invalid ${names.list} input — \`status\` must be a string.`; + } + try { + const tasks = await def.store.list({ + kind: def.kind, + ...(statusRaw !== undefined + ? { status: statusRaw as TaskLifecycleStatus } + : {}), + }); + return JSON.stringify(tasks.map(compact)); + } catch (err: unknown) { + return errString(`${names.list} failed`, err); + } + }; + + return { + registrations: [ + { name: names.start, spec: startSpec, promptDoc: startPromptDoc, handler: handleStart }, + { name: names.status, spec: statusSpec, promptDoc: statusPromptDoc, handler: handleStatus }, + { name: names.list, spec: listSpec, promptDoc: listPromptDoc, handler: handleList }, + ], + takePendingCards(): readonly TaskCardPayload[] { + const cards = [...pendingCards]; + pendingCards.length = 0; + return cards; + }, + hasPendingCards(): boolean { + return pendingCards.length > 0; + }, + async drainForTest(): Promise { + while (inFlight.size > 0) { + await Promise.all([...inFlight]); + } + }, + }; +} diff --git a/middleware/packages/harness-orchestrator/src/tasks/subAgentTaskTool.ts b/middleware/packages/harness-orchestrator/src/tasks/subAgentTaskTool.ts new file mode 100644 index 00000000..4430676d --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/tasks/subAgentTaskTool.ts @@ -0,0 +1,117 @@ +/** + * W2-2 criterion 4 — the SECOND consumer of the long-running task seam: + * sub-agent dispatch. + * + * This is the case that motivated the whole unit. A `DomainTool` built by + * `registry/subAgentTools.ts` / `tools/domainQueryTool.ts` wraps an + * {@link Askable} — in practice a `LocalSubAgent` running its own multi-turn LLM + * loop with its own tools. `DomainTool.handle()` AWAITS that loop, so the parent + * turn blocks for as long as the sub-agent runs: minutes for a research-shaped + * question. That is exactly the failure the seam removes. + * + * Wrapping the same `Askable` here yields `ask__start` / + * `ask__status` / `ask__list`: the parent turn gets a handle in + * milliseconds, the sub-agent runs detached, and a following turn collects the + * answer via `_status`. + * + * ## Why this needed no migration + * + * The sub-agent's task state is process-local ({@link InMemoryTaskStore}) — a + * deliberate W2-2 boundary, since `0031`/`0032` are taken by parallel units and + * this unit ships no schema change. A restart therefore drops in-flight + * sub-agent tasks; they are reaped/absent rather than silently wrong, and a poll + * says so. Making them durable is the follow-up that owns the migration. + * + * ## Blocking and non-blocking coexist + * + * This does NOT replace the blocking `ask_` DomainTool. Both can be + * registered: a question the sub-agent answers in seconds should stay inline + * (one turn, one answer), and only genuinely slow sub-agents want the deferred + * shape. Which sub-agents get it is an opt-in decision by the caller, so a + * `LocalSubAgent` that returns quickly is never made worse. + */ + +import type { AskObserver, Askable } from '../tools/domainQueryTool.js'; + +import { InMemoryTaskStore } from './inMemoryTaskStore.js'; +import { + defineLongRunningTool, + type LongRunningToolHandle, +} from './longRunningTool.js'; +import type { TaskStore } from './taskTypes.js'; + +/** Prefix mirroring `subAgentToolName()`'s `ask_` convention. */ +export const SUB_AGENT_TASK_KIND_PREFIX = 'subagent'; + +export interface LongRunningSubAgentToolOptions { + /** + * The sub-agent's tool base name — pass the SAME value + * `subAgentToolName(sub.name)` produced (e.g. `ask_hr`), so the deferred + * triple is recognisably the same agent as the inline tool. + */ + readonly baseToolName: string; + /** Human-readable sub-agent name, used as the non-sensitive card label. */ + readonly displayName: string; + /** What the sub-agent is for — shown to the parent model. */ + readonly description: string; + /** The wrapped sub-agent. `LocalSubAgent` satisfies this. */ + readonly agent: Askable; + /** Share one store across sub-agents, or omit for a private one. */ + readonly store?: TaskStore; + /** Forwarded to `agent.ask` when the caller wants inner-loop observability. */ + readonly observer?: AskObserver; + readonly onRunnerError?: (err: unknown, taskId: string) => void; +} + +/** + * Wrap an {@link Askable} sub-agent as a non-blocking tool triple. + * + * The executor is thin on purpose: `agent.ask()` is the whole operation, and the + * phase/log calls around it exist so the status card has something truthful to + * show while the sub-agent thinks. + */ +export function createLongRunningSubAgentTool( + opts: LongRunningSubAgentToolOptions, +): LongRunningToolHandle { + const store = opts.store ?? new InMemoryTaskStore(); + return defineLongRunningTool({ + toolName: opts.baseToolName, + longRunning: true, + kind: `${SUB_AGENT_TASK_KIND_PREFIX}.${opts.displayName}`, + cardLabel: opts.displayName, + startDescription: + `${opts.description} Runs the "${opts.displayName}" sub-agent ` + + 'ASYNCHRONOUSLY: this call returns immediately with a task id, not with ' + + 'the answer. Use the matching `_status` tool to collect the answer once ' + + 'it is `completed`.', + inputProperties: { + question: { + type: 'string', + description: + 'The single, self-contained question for the sub-agent — including ' + + 'every id, name, and time range it needs, since you cannot clarify ' + + 'mid-run.', + }, + }, + requiredInput: ['question'], + validateInput: (input: unknown): string | null => { + const question = (input as Record)['question']; + if (typeof question !== 'string' || question.trim().length === 0) { + return '`question` must be a non-empty string.'; + } + return null; + }, + store, + ...(opts.onRunnerError ? { onRunnerError: opts.onRunnerError } : {}), + execute: async (input, handle): Promise => { + const question = String((input as Record)['question']); + await handle.setPhase('asking'); + // Truncated preview only — a card/event line must stay non-sensitive and + // bounded, and the full question is already in the task's stored input. + await handle.log('tool', `delegating to ${opts.displayName}`); + const answer = await opts.agent.ask(question, opts.observer); + await handle.setPhase('answered'); + return answer; + }, + }); +} diff --git a/middleware/packages/harness-orchestrator/src/tasks/taskReaper.ts b/middleware/packages/harness-orchestrator/src/tasks/taskReaper.ts new file mode 100644 index 00000000..07aad594 --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/tasks/taskReaper.ts @@ -0,0 +1,114 @@ +/** + * W2-2 criterion 7 — orphan handling for long-running tasks. + * + * A task nobody polls must not leak a `working` row forever. Two distinct + * leaks exist and both are swept here, in the usual two-tier retention shape: + * + * 1. ABANDONED live tasks. The worker crashed, the process restarted mid-run, + * or the runner was never started at all. The row stays `working` with a + * frozen heartbeat and the model's `_status` poll answers "working" forever + * — a lie. These are force-failed with an explicit error, so a poll gets the + * truth. + * + * NOT included: `input_required`. A parked task is blocked on a HUMAN, and + * a human has no heartbeat — `requireInput` releases the lease and freezes + * `lastHeartbeatAt`, so the worker-liveness window judged parked tasks by a + * signal that can never arrive and force-failed every card the user took + * longer than {@link DEFAULT_TASK_STALE_AFTER_MS} to answer. Parked tasks + * expire only under the explicit, opt-in + * {@link TaskReaperOptions.parkedStaleAfterMs}. + * + * 2. ACCUMULATED terminal tasks. Nothing ever deletes a `completed` row: the + * model may never poll it, and even when it does, it does not clean up. + * Terminal rows older than the retain window are purged. + * + * The sweep itself lives on the store (`TaskStore.reapOrphans`) so a Postgres + * implementor can do it in two statements. This module only owns the SCHEDULE, + * kept separate so the sweep stays unit-testable against a driven clock. + */ + +import type { TaskReapResult, TaskStore } from './taskTypes.js'; + +/** Default: a live task silent for 15 min is abandoned. Long enough for a slow + * sub-agent turn, short enough that a crashed worker is not believed for long. */ +export const DEFAULT_TASK_STALE_AFTER_MS = 15 * 60_000; + +/** Default: a finished task is retained an hour, so a following turn can still + * collect its result, then purged. */ +export const DEFAULT_TASK_PURGE_TERMINAL_AFTER_MS = 60 * 60_000; + +/** Default sweep cadence. */ +export const DEFAULT_TASK_REAP_INTERVAL_MS = 5 * 60_000; + +export interface TaskReaperOptions { + readonly staleAfterMs?: number; + /** + * Explicit expiry for human-parked (`input_required`) tasks. There is NO + * default: omitted ⇒ parked tasks are never force-failed, only purged once + * they reach a terminal state some other way. See the module header. + */ + readonly parkedStaleAfterMs?: number; + readonly purgeTerminalAfterMs?: number; + readonly intervalMs?: number; + readonly onSweep?: (result: TaskReapResult) => void; + readonly onError?: (err: unknown) => void; +} + +/** Run exactly one sweep. Exported so a test can drive it without timers. */ +export async function runTaskReaperOnce( + store: TaskStore, + opts: TaskReaperOptions = {}, + now?: Date, +): Promise { + return store.reapOrphans({ + ...(now !== undefined ? { now } : {}), + staleAfterMs: opts.staleAfterMs ?? DEFAULT_TASK_STALE_AFTER_MS, + // Forwarded ONLY when the operator set it — no fallback, deliberately. + ...(opts.parkedStaleAfterMs !== undefined + ? { parkedStaleAfterMs: opts.parkedStaleAfterMs } + : {}), + purgeTerminalAfterMs: + opts.purgeTerminalAfterMs ?? DEFAULT_TASK_PURGE_TERMINAL_AFTER_MS, + }); +} + +/** + * Start the periodic sweep. Returns a dispose function. + * + * The timer is `unref`'d so it never keeps the process alive on shutdown — a + * pending reap is not worth delaying an exit for; the next boot sweeps anyway. + * + * Sweeps do not overlap. `setInterval` does not await its callback, so a sweep + * slower than `intervalMs` would otherwise be re-entered while still running. + * Harmless against `InMemoryTaskStore` (its `reapOrphans` never suspends, so it + * is one atomic JS task) — but the seam exists precisely so a Postgres + * implementor can back it, and there a slow sweep would stack concurrent + * transactions contending on the same rows. The guard is cheaper than relying + * on every future implementor being fast. + */ +export function startTaskReaper( + store: TaskStore, + opts: TaskReaperOptions = {}, +): () => void { + const intervalMs = opts.intervalMs ?? DEFAULT_TASK_REAP_INTERVAL_MS; + let sweeping = false; + const timer = setInterval(() => { + if (sweeping) return; + sweeping = true; + void runTaskReaperOnce(store, opts) + .then( + (result) => opts.onSweep?.(result), + (err: unknown) => { + if (opts.onError) opts.onError(err); + else console.warn('[taskReaper] sweep failed:', err); + }, + ) + .finally(() => { + sweeping = false; + }); + }, intervalMs); + timer.unref?.(); + return (): void => { + clearInterval(timer); + }; +} diff --git a/middleware/packages/harness-orchestrator/src/tasks/taskTypes.ts b/middleware/packages/harness-orchestrator/src/tasks/taskTypes.ts new file mode 100644 index 00000000..fed91451 --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/tasks/taskTypes.ts @@ -0,0 +1,332 @@ +/** + * W2-2 (issue #543, rescoped) — the generic long-running task seam. + * + * ## Why this exists + * + * A chat turn is a streaming SSE response with a heartbeat that ends when the + * model loop ends. There is NO park/resume for a chat turn: holding the stream + * open for minutes is strictly worse than returning early (proxy idle timeouts, + * Teams activity expiry, connection reaping). So a tool that genuinely takes + * minutes must NOT block the turn — it returns a HANDLE immediately and the + * model says "started, I'll report back". + * + * That shape was first hand-rolled inside a single tool, which returned + * `{status:'started', id, phase:'queued'}` at once and left the caller to poll. + * This module lifts the shape out into a seam so ANY tool can opt in, without a + * second bespoke job store. + * + * Deliberately NOT the MCP Tasks extension. Internal sub-agent dispatches never + * cross an MCP boundary, so MCP Tasks solves nothing for the motivating case, + * and the redesigned extension (SEP-2663) is not shipped even in SDK v2 — + * `tasks/update` does not exist. The status vocabulary below is nonetheless + * CHOSEN to match MCP Tasks (`working | input_required | completed | failed`) + * so a later protocol projection is a mechanical mapping, not a redesign. + * + * ## Layering + * + * `TaskReadStore` is the model-facing read surface (get / list / event tail). + * `TaskStore` adds the write half: create, claim-with-lease, heartbeat, event + * append, and the terminal transition. Splitting them lets a consumer expose + * reads to a tool while keeping the write half behind its own choke point — + * which is what an implementor needs whenever its terminal write must pass its + * own gate (an approval step, a policy check) rather than being reachable by + * anything holding the read surface. + * + * This module has NO dependencies beyond the type system so both the + * orchestrator package and the middleware app can import it freely. + */ + +// --------------------------------------------------------------------------- +// Status vocabulary — chosen to project cleanly onto MCP Tasks (SEP-2663). +// --------------------------------------------------------------------------- + +/** + * Task lifecycle. Intentionally a FOUR-value vocabulary, matching MCP Tasks: + * + * - `working` — queued or executing. The handle is live. + * - `input_required` — blocked on a human decision (a gate). Still live. + * - `completed` — finished successfully; `result` is populated. + * - `failed` — finished unsuccessfully; `error` is populated. + * + * A richer per-implementor status set (a ten-value pipeline vocabulary, say) + * projects DOWN onto this; the implementor keeps its own vocabulary internally + * and reports the projection here. `phase` carries the + * implementor-specific progress label so the projection loses no information + * the model or a card actually needs. + */ +export const TASK_LIFECYCLE_STATUSES = [ + 'working', + 'input_required', + 'completed', + 'failed', +] as const; +export type TaskLifecycleStatus = (typeof TASK_LIFECYCLE_STATUSES)[number]; + +/** Terminal statuses — no further transition is legal once reached. */ +export const TERMINAL_TASK_STATUSES = ['completed', 'failed'] as const satisfies + readonly TaskLifecycleStatus[]; + +export function isTaskLifecycleStatus(x: unknown): x is TaskLifecycleStatus { + return ( + typeof x === 'string' && + (TASK_LIFECYCLE_STATUSES as readonly string[]).includes(x) + ); +} + +export function isTerminalTaskStatus(x: unknown): x is TaskLifecycleStatus { + return ( + typeof x === 'string' && + (TERMINAL_TASK_STATUSES as readonly string[]).includes(x) + ); +} + +/** + * Lease tokens are UUIDs, and a non-UUID is + * rejected LOUDLY at the seam rather than surfacing as an opaque Postgres + * `22P02` from a `uuid` column cast further down. + */ +export const TASK_LEASE_UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// --------------------------------------------------------------------------- +// Descriptor + events. +// --------------------------------------------------------------------------- + +/** + * The implementor-agnostic view of one long-running task. + * + * PRIVACY INVARIANT: `result` may contain arbitrary tool output, including PII. + * It is only ever surfaced through the `_status` handler — i.e. as a + * normal tool result inside a LIVE turn, where the orchestrator's `dispatchTool` + * privacy pass applies. It must NEVER be copied into a {@link TaskCardPayload} + * (cards bypass `dispatchTool`). See the note on `TaskCardPayload`. + */ +export interface TaskDescriptor { + readonly id: string; + /** Implementor-defined subtype (e.g. `'fix_issue'`, `'subagent.hr'`). */ + readonly kind: string; + readonly status: TaskLifecycleStatus; + /** Implementor-specific progress label (e.g. `'queued'`, `'plan'`). */ + readonly phase: string; + readonly createdAt: string; + readonly updatedAt: string; + /** Set exactly when `status` is terminal. */ + readonly endedAt: string | null; + /** Lease token of the worker currently executing, or `null`. */ + readonly claimedBy: string | null; + readonly lastHeartbeatAt: string | null; + /** Populated on `completed`. See the privacy invariant above. */ + readonly result: string | null; + /** Populated on `failed`. */ + readonly error: string | null; +} + +/** One line of a task's event tail. `seq` is monotonic per task. */ +export interface TaskEventRecord { + readonly seq: number; + readonly ts: string; + readonly type: string; + readonly message: string; +} + +/** What `create` needs. Ids/timestamps are generated by the store. */ +export interface NewTaskInput { + readonly kind: string; + /** The opaque payload the executor receives when the task is claimed. */ + readonly input: unknown; + /** Initial progress label. Defaults to `'queued'`. */ + readonly phase?: string; + /** Who asked for it — for scoping reads. */ + readonly createdBy?: string; +} + +/** Fields a terminal transition may set alongside the status flip. */ +export interface TerminalTaskPatch { + readonly status: 'completed' | 'failed'; + readonly result?: string; + readonly error?: string; + readonly phase?: string; +} + +export interface TaskListFilter { + readonly kind?: string; + readonly status?: TaskLifecycleStatus; + readonly createdBy?: string; + readonly limit?: number; +} + +/** + * Thrown when a lease-fenced write updates 0 rows — the task's lease no longer + * matches this worker's (another worker claimed it, or it is already terminal). + * The worker catches this and stops. Mirrors conductor's `RunLeaseLostError`. + */ +export class TaskLeaseLostError extends Error { + constructor(taskId: string) { + super( + `task '${taskId}' lease lost (claimed by another worker or already terminal)`, + ); + this.name = 'TaskLeaseLostError'; + } +} + +// --------------------------------------------------------------------------- +// Card payload — what streams into the conversation. +// --------------------------------------------------------------------------- + +/** + * The live status card seeded into the chat stream by a `_start` call. + * + * PRIVACY INVARIANT (load-bearing — enforced by test): a card carries ONLY + * non-sensitive routing metadata: ids, the projected status, the progress + * label, and the URL the card polls. It carries NO task result and NO task + * input. Cards are rendered client-side from the tool result / stream event and + * therefore do NOT pass through `Orchestrator.dispatchTool`, so anything put + * here would escape the Privacy Shield data-plane boundary entirely. + */ +export interface TaskCardPayload { + readonly taskId: string; + /** The `_start` tool that produced this card. */ + readonly toolName: string; + readonly kind: string; + readonly status: TaskLifecycleStatus; + readonly phase: string; + /** Short, non-sensitive human label (e.g. the sub-agent's display name). */ + readonly label: string; + /** Where the card polls / subscribes for updates; `null` ⇒ poll via tool. */ + readonly eventsUrl: string | null; +} + +// --------------------------------------------------------------------------- +// The store seam. +// --------------------------------------------------------------------------- + +/** The read half — everything the model-facing `_status` / `_list` tools need. */ +export interface TaskReadStore { + get(id: string): Promise; + list(filter?: TaskListFilter): Promise; + /** Newest `limit` events, oldest-first. */ + eventTail(id: string, limit: number): Promise; +} + +/** + * The full seam: reads plus the write half. + * + * Claim/lease semantics: + * - `claimNextPending` atomically hands ONE unclaimed `working` task to the + * caller and stamps the caller's lease. Two concurrent workers never get the + * same task. + * - every subsequent WORKER write is FENCED on that lease; a write whose lease + * no longer matches throws {@link TaskLeaseLostError} rather than silently + * winning. + * - `finish` is the single terminal transition, and it is fenced too — so a + * worker that lost its lease cannot overwrite the outcome the new owner + * recorded. + * - `reapOrphans` is the ONE deliberately unfenced writer, and the qualifier + * above exists for it: an orphan sweep by definition runs when the lease + * holder is gone, so it force-fails rows that still carry a live + * `claimedBy`. It is administrative, not a worker write, and it is why + * terminal immutability is a separate guard from the lease check — that + * guard, not the lease, is what rejects the zombie afterwards. Do not read + * "every write is lease-fenced" as covering the sweep; it never has. + * + * An implementor MAY additionally accept a fenced write against a task that + * currently holds NO lease — the administrative case, where a cancel route or an + * orphan reaper legitimately finalizes a task it never claimed. Any implementor + * that exposes a cancel route relies on exactly that, so its terminal write is + * deliberately unfenced against a row holding NO lease. What an implementor must + * NEVER accept is a MISMATCHED lease against a task that does hold one; that is + * the property the fence exists for. + */ +export interface TaskStore extends TaskReadStore { + create(input: NewTaskInput): Promise; + /** + * Claim the oldest unclaimed `working` task, optionally restricted to one + * `kind`. `lease` must be a UUID (see {@link TASK_LEASE_UUID_RE}). + * Returns the claimed descriptor plus the stored input, or `null`. + * + * `taskId` is an ADVISORY hint: "I was spawned for this specific task, prefer + * it over the pool head". A store that can honour it (see + * `InMemoryTaskStore`) claims exactly that task or nothing. A store whose + * underlying claim is a pure pool pop, and which offers no release primitive, + * may IGNORE the hint and + * return whatever it claimed — which is why the hint is advisory rather than + * a filter contract, and why every caller must treat the RETURNED + * `descriptor.id` as authoritative and follow it through. Claiming a task and + * then walking away because it was not the expected one strands it under a + * dead lease until the reaper fails it. + */ + claimNextPending( + lease: string, + kind?: string, + taskId?: string, + ): Promise<{ descriptor: TaskDescriptor; input: unknown } | null>; + /** Lease-fenced liveness touch. Throws {@link TaskLeaseLostError} on 0 rows. */ + heartbeat(id: string, lease: string): Promise; + /** Lease-fenced progress label update. */ + setPhase(id: string, lease: string, phase: string): Promise; + /** Lease-fenced event append. Also bumps the heartbeat. */ + appendEvents( + id: string, + lease: string, + events: readonly { type: string; message: string }[], + ): Promise; + /** Lease-fenced terminal transition — the ONE way a task ends. */ + finish( + id: string, + lease: string, + patch: TerminalTaskPatch, + ): Promise; + /** Lease-fenced flip to `input_required` (a human gate). */ + requireInput(id: string, lease: string, phase?: string): Promise; + /** + * Orphan sweep (criterion 7). A task nobody polls must not leak a `working` + * row forever. See `taskReaper.ts` for the scheduled driver. + */ + reapOrphans(opts: TaskReapOptions): Promise; +} + +/** Windows for one orphan sweep. All durations in ms; all must be positive. */ +export interface TaskReapOptions { + /** Injected clock so tests drive it deterministically. */ + readonly now?: Date; + /** + * A `working` task whose last heartbeat is older than this is failed as + * abandoned. Its worker is gone (crash, restart, deploy). + * + * Deliberately does NOT cover `input_required`. A parked task is waiting on a + * HUMAN, not on a worker: `requireInput` releases the lease, nothing + * heartbeats it, and its heartbeat is frozen at the instant it parked. Judging + * it by the worker-liveness window meant a user who answered an + * `input_required` card 16 minutes later landed on a task the generic sweep + * had already marked `failed`. Parked tasks have their own, explicit window — + * see {@link parkedStaleAfterMs}. + */ + readonly staleAfterMs: number; + /** + * Optional, MUCH longer ceiling for `input_required` tasks, measured from + * `updatedAt` (when the task parked or last changed) rather than from the + * frozen heartbeat. + * + * OMITTED ⇒ parked tasks are never force-failed by the sweep. That is the + * default because a human has no SLA: the honest bound on a parked task is + * the store's own retention, not the worker-liveness window. Supply it only + * when a deployment genuinely wants parked work to expire, and give it a value + * measured in hours, not minutes. + */ + readonly parkedStaleAfterMs?: number; + /** Terminal tasks older than this are deleted outright. */ + readonly purgeTerminalAfterMs: number; +} + +export interface TaskReapResult { + /** + * Tasks force-failed by this sweep: `working` tasks past + * {@link TaskReapOptions.staleAfterMs}, plus — only when + * {@link TaskReapOptions.parkedStaleAfterMs} was supplied — `input_required` + * tasks past that separate window. The two carry different `error` strings on + * the row, so an operator can still tell them apart. + */ + readonly staleFailed: number; + /** Terminal tasks deleted. */ + readonly purged: number; +} diff --git a/middleware/packages/harness-orchestrator/src/toolCallerContext.ts b/middleware/packages/harness-orchestrator/src/toolCallerContext.ts new file mode 100644 index 00000000..e761ed1e --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/toolCallerContext.ts @@ -0,0 +1,38 @@ +/** + * Ambient caller identity for the standalone tool-dispatch path (#542 prerequisite). + * + * `ToolDispatchService` historically carried no caller identity at all. That is + * fine for the loopback bridge — the caller is the local CLI acting as the + * session's own user — but a public endpoint receives every call with an API key + * or token behind it, and the layers beneath dispatch (MCP audit rows, plugin + * handlers, downstream integrations) need to be able to attribute the call. + * + * Propagated ambiently via `AsyncLocalStorage` rather than as a handler parameter + * for the same reason the idempotency key is: `NativeToolHandler` is + * `(input: unknown) => Promise`, a published contract implemented by + * out-of-tree plugins. Widening it is not an option, and it is the same mechanism + * the privacy handle already uses via `turnContext`. + * + * This is a CARRIER, not an authorization boundary. Reading a principal here says + * who claims to be calling — it does not mean anything checked their scopes. + * Enforcement belongs with the endpoint that authenticated the credential. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +import type { ToolDispatchCallerContext } from './toolDispatchService.js'; + +const callerStorage = new AsyncLocalStorage(); + +/** Caller identity of the in-flight dispatch, if the entry point supplied one. */ +export function currentDispatchCaller(): ToolDispatchCallerContext | undefined { + return callerStorage.getStore(); +} + +/** Run `fn` with `caller` visible to every layer beneath it. */ +export function runWithDispatchCaller( + caller: ToolDispatchCallerContext, + fn: () => T, +): T { + return callerStorage.run(caller, fn); +} diff --git a/middleware/packages/harness-orchestrator/src/toolDispatchService.ts b/middleware/packages/harness-orchestrator/src/toolDispatchService.ts index 4a23fc2c..3f0fef53 100644 --- a/middleware/packages/harness-orchestrator/src/toolDispatchService.ts +++ b/middleware/packages/harness-orchestrator/src/toolDispatchService.ts @@ -1,18 +1,53 @@ /** - * M1 library code for #309 Shape-3 OpenClaw. + * Standalone tool dispatcher — the entry point that is NOT the Orchestrator turn + * loop. Serves the loopback MCP server (subscription-CLI provider), the CLI + * bridge, and CLI sub-agents; it is also the path any future public MCP endpoint + * (#542) would dispatch through. * - * This standalone dispatcher executes tools outside the Orchestrator turn loop. - * It intentionally replicates only the native-handler and DomainTool branches - * of `Orchestrator.dispatchToolInner`; kernel-tool branches plus privacy/trace - * seams are deferred to M2. + * It replicates the native-handler and DomainTool branches of + * `Orchestrator.dispatchToolInner`, and — since #542's prerequisite work — the + * privacy data-plane boundary and raw-result capture that `dispatchToolDeadlined` + * applies around them. See the SEAM note at the bottom of this file for what is + * closed and what is still deliberately orchestrator-only. */ +import { isInternExemptTool } from './privacyInternPolicy.js'; +import { isWriteCapableTool } from '@omadia/plugin-api'; +import type { WriteCapability } from '@omadia/plugin-api'; +import type { PrivacyTurnHandle } from './privacyHandle.js'; import type { DomainTool } from './tools/domainQueryTool.js'; import type { NativeToolRegistry } from './nativeToolRegistry.js'; +import { sortByToolName } from './toolOrdering.js'; +import { turnContext } from './turnContext.js'; +import { runWithDispatchCaller } from './toolCallerContext.js'; +import { runWithIdempotencyScope } from './toolIdempotency.js'; +import type { ToolIdempotencyStore } from './toolIdempotency.js'; + +/** + * Who authored a `ToolDispatchResult.content`, and therefore whether it had to + * cross the privacy boundary. + * + * - `'tool'` — produced by a tool handler: its return value, or the + * message of the exception it threw. UNTRUSTED. It carries + * whatever the handler (and the ORM/driver beneath it) chose + * to put in it, so it must be masked before it reaches an + * untrusted caller. + * - `'dispatcher'` — produced by this service's own guards (unknown tool, + * plugin not ready). Contains only the tool name the caller + * itself supplied and the owning plugin id; never tool data, + * so there is nothing for masking to have crossed. + * + * A consumer that gates on this MUST treat an ABSENT value as `'tool'`: a + * dispatcher that predates this field, or a future one that forgets it, has to + * fail toward "must be masked". + */ +export type ToolDispatchContentOrigin = 'tool' | 'dispatcher'; export interface ToolDispatchResult { readonly content: string; readonly isError?: boolean; + /** See `ToolDispatchContentOrigin`. Absent ⇒ treat as `'tool'`. */ + readonly origin?: ToolDispatchContentOrigin; } export interface DispatchableToolSpec { @@ -25,6 +60,54 @@ export interface DispatchableToolSpec { }; } +/** + * Identity of whoever asked for this dispatch. + * + * The dispatch path historically carried NO caller identity at all — no tenant, + * no user, no principal — which is fine for the loopback bridge (the caller is + * the local CLI, acting as the session's own user) but is the missing seam for a + * public endpoint, where every call arrives with an API key or token that has to + * be attributable and scope-checked. + * + * Optional by construction: the loopback and CLI-sub-agent paths pass nothing and + * behave exactly as before. When #438/#439's `harness-api-key-auth` lands, the + * public endpoint fills this in from the verified credential; nothing downstream + * has to change shape again. + * + * NOTE: this is a CARRIER, not an enforcement point. `ToolDispatchService` does + * not currently authorize against `scopes` — a per-principal tool allowlist is + * the public endpoint's own job (#542) and belongs where the allowlist policy + * lives, not here. Do not read the presence of this field as "the dispatch path + * is now access-controlled". + */ +export interface ToolDispatchCallerContext { + /** Stable id of the acting principal (API-key id, service account, user id). */ + readonly principal?: string; + /** Scopes/permissions the credential carries, for the caller's own policy check. */ + readonly scopes?: readonly string[]; + /** Tenant the call is acting within. */ + readonly tenantId?: string; + /** End user on whose behalf the call runs, when distinct from `principal`. */ + readonly userId?: string; + /** Correlation id for logs/traces. */ + readonly requestId?: string; +} + +/** Per-dispatch options. All optional — omitting the whole argument is legacy behaviour. */ +export interface ToolDispatchOptions { + readonly caller?: ToolDispatchCallerContext; + /** + * Caller-supplied idempotency key. Applied ONLY to write-capable tools (see + * `isWriteCapableTool`): two dispatches sharing a key execute the tool at most + * once while the record is live, and the MCP transport layer suppresses its + * transient retry for the call. + * + * Read tools ignore this: deduping reads would serve stale data, and the + * flaky-proxy retry mitigation must stay in force for them. + */ + readonly idempotencyKey?: string; +} + export class ToolDispatchService { constructor( private readonly deps: { @@ -45,6 +128,42 @@ export class ToolDispatchService { * ungated. Absent ⇒ every plugin's tools are always available. */ readonly isPluginToolsReady?: (agentId: string) => boolean; + /** + * #542 prerequisite — the privacy data-plane boundary for this path. + * + * The chat path reads its handle from `turnContext`, which this dispatcher + * runs entirely outside of: the loopback MCP server and any public endpoint + * are not inside `turnContext.run(...)`, so `turnContext.current()` is + * `undefined` and a tool result would reach the caller with PII intact. + * That was the open half of the privacy seam. + * + * Resolution order is explicit-dep first, ambient turn context second, so + * a host that DOES dispatch from inside a turn still inherits that turn's + * handle. Absent from both ⇒ no privacy provider installed and results flow + * through unchanged, matching the orchestrator. + */ + readonly privacy?: () => PrivacyTurnHandle | undefined; + /** + * #542 prerequisite — raw-result capture (the orchestrator's Phase C.2 + * `captureRawToolResult`). Receives the tool result BEFORE masking, so a + * trace/audit consumer sees ground truth while the caller gets the digest. + * Must not throw; a throw is caught and logged rather than failing the call. + * + * Receives the dispatch's caller context so an audit consumer can attribute + * the result to the principal that caused it. + */ + readonly captureRawToolResult?: ( + name: string, + result: string, + caller?: ToolDispatchCallerContext, + ) => void; + /** + * #542 prerequisite — dedupe store for write-capable dispatches. Absent ⇒ + * `idempotencyKey` is inert and every dispatch executes (legacy behaviour). + * Process-local: see `toolIdempotency.ts` for the exact limits of the + * guarantee — it is NOT distributed idempotency. + */ + readonly idempotency?: ToolIdempotencyStore; }, ) {} @@ -59,7 +178,66 @@ export class ToolDispatchService { return this.deps.isPluginToolsReady(agentId); } - async dispatch(name: string, input: unknown): Promise { + /** Explicit dep wins; ambient turn handle is the fallback. */ + private privacyHandle(): PrivacyTurnHandle | undefined { + return this.deps.privacy?.() ?? turnContext.current()?.privacyHandle; + } + + /** Declared write capabilities for `name`, from whichever carrier owns it. */ + private writeCapabilities(name: string): readonly WriteCapability[] | undefined { + const native = this.deps.nativeTools.get(name); + if (native?.writeCapabilities !== undefined) return native.writeCapabilities; + return this.domainTools().find((t) => t.name === name)?.writeCapabilities; + } + + /** True when dispatching `name` may mutate data. */ + isWriteCapable(name: string): boolean { + return isWriteCapableTool(this.writeCapabilities(name)); + } + + async dispatch( + name: string, + input: unknown, + options?: ToolDispatchOptions, + ): Promise { + const caller = options?.caller; + // Publish caller identity for every layer beneath this dispatch. Omitted + // entirely when the entry point supplied none, so the loopback path runs with + // an empty store exactly as before. + return caller === undefined + ? this.dispatchIdempotent(name, input, options) + : runWithDispatchCaller(caller, () => + this.dispatchIdempotent(name, input, options), + ); + } + + private async dispatchIdempotent( + name: string, + input: unknown, + options?: ToolDispatchOptions, + ): Promise { + const key = options?.idempotencyKey; + const store = this.deps.idempotency; + // Idempotency applies to write-capable tools only. A read tool keeps the + // transport-retry mitigation and never replays a cached body. + if (key !== undefined && store !== undefined && this.isWriteCapable(name)) { + const outcome = await store.run(key, name, input, () => + // The scope must wrap the EXECUTION, not the cache lookup, so the MCP + // transport layer beneath the handler can read it and suppress its retry. + runWithIdempotencyScope({ key, toolName: name, exactlyOnce: true }, () => + this.dispatchInner(name, input, options), + ), + ); + return outcome.result; + } + return this.dispatchInner(name, input, options); + } + + private async dispatchInner( + name: string, + input: unknown, + options?: ToolDispatchOptions, + ): Promise { const nativeRegistration = this.deps.nativeTools.get(name); // Mirrors Orchestrator ordering: plugin/native handlers win first. if (nativeRegistration?.handler) { @@ -67,12 +245,18 @@ export class ToolDispatchService { return { content: `Error: tool \`${name}\` is unavailable — plugin \`${nativeRegistration.agentId}\` has not completed its connection/auth setup.`, isError: true, + origin: 'dispatcher', }; } try { - return { content: await nativeRegistration.handler(input) }; + const raw = await nativeRegistration.handler(input); + return { content: await this.afterDispatch(name, raw, options), origin: 'tool' }; } catch (error) { - return { content: this.errMsg(error), isError: true }; + return { + content: await this.maskErrorText(name, this.errMsg(error)), + isError: true, + origin: 'tool', + }; } } @@ -85,16 +269,162 @@ export class ToolDispatchService { return { content: `Error: tool \`${name}\` is unavailable — plugin \`${domainTool.agentId}\` has not completed its connection/auth setup.`, isError: true, + origin: 'dispatcher', }; } try { - return { content: await domainTool.handle(input) }; + const raw = await domainTool.handle(input); + return { content: await this.afterDispatch(name, raw, options), origin: 'tool' }; } catch (error) { - return { content: this.errMsg(error), isError: true }; + return { + content: await this.maskErrorText(name, this.errMsg(error)), + isError: true, + origin: 'tool', + }; + } + } + + return { content: `Error: unknown tool \`${name}\`.`, isError: true, origin: 'dispatcher' }; + } + + /** + * Post-dispatch pipeline: raw capture, then the privacy data-plane boundary. + * + * Ordering mirrors `Orchestrator.dispatchToolDeadlined` deliberately, because a + * divergence here is a privacy divergence: + * 1. raw capture — trace/audit consumers must see ground truth + * 2. intern-exemption — the agent's own infra tools are never masked + * 3. operator bypass (+ receipt entry) — explicit opt-out stays auditable + * 4. intern — the caller receives the identity-free digest + */ + private async afterDispatch( + name: string, + result: string, + options?: ToolDispatchOptions, + ): Promise { + const capture = this.deps.captureRawToolResult; + if (capture !== undefined && typeof result === 'string') { + try { + capture(name, result, options?.caller); + } catch (err) { + console.warn( + `[toolDispatchService:${name}] captureRawToolResult threw — continuing without capture:`, + err, + ); + } + } + + const privacy = this.privacyHandle(); + if (privacy === undefined || typeof result !== 'string') return result; + + // Interning-exemption: the agent's own infrastructure/self tools (memory, + // stored-process CRUD, self-produced meta output) are never interned — + // masking them blinds the agent to its own operational state. Same + // auditable allowlist the orchestrator uses. + if (isInternExemptTool(name)) return result; + + // Operator-owned per-plugin bypass (Slice 2.5). Raw passthrough, but the + // receipt entry keeps it transparent. + const bypass = privacy.checkBypass(name); + if (bypass !== undefined) { + try { + await privacy.recordBypassedTool({ + toolName: name, + pluginId: bypass.pluginId, + reason: 'operator_setting', + bytes: Buffer.byteLength(result, 'utf8'), + }); + } catch (err) { + console.warn( + `[toolDispatchService:${name}] privacy.recordBypassedTool threw — bypass still applied:`, + err, + ); } + return result; } - return { content: `Error: unknown tool \`${name}\`.`, isError: true }; + try { + const v4 = await privacy.internToolResultV4({ + toolName: name, + rawResult: result, + }); + return v4.digestText; + } catch (err) { + // Fail-OPEN, matching `Orchestrator.dispatchToolDeadlined` exactly. This is + // parity, not an endorsement: for a PUBLIC endpoint a masking failure that + // emits raw rows is a leak, and a fail-CLOSED policy for untrusted callers + // is worth its own decision (#542) — but making this path stricter than the + // chat path would be a silent behaviour change beyond closing the seam. + console.warn( + `[toolDispatchService:${name}] privacy.internToolResultV4 threw — sending raw result:`, + err, + ); + return result; + } + } + + /** + * The masking half of `afterDispatch`, applied to the message of an exception + * a tool handler THREW. + * + * ─── Why the error path needed this at all ────────────────────────────────── + * + * `afterDispatch` ran only on the success path, so a throwing handler took a + * branch that skipped the entire privacy boundary and returned the raw + * exception text. Handler exceptions are not sanitized strings: an ORM echoes + * the failing row, a driver echoes the bound query parameters. `Fault: Invalid + * field 'x' on record {'id':42,'name':'Jane Doe','email':'jane@acme.de'}` is a + * perfectly ordinary Odoo error, and it went to the caller verbatim. + * + * ─── Why NOT just call `afterDispatch` ────────────────────────────────────── + * + * Two of its four steps are wrong for an exception, and reusing the whole + * chain would have imported both: + * + * - **Raw capture.** `captureRawToolResult` is documented as receiving "the + * tool result", and its consumers (trace/audit, and on the chat path the + * Knowledge-Graph ingest) treat it as business data. A driver stack trace + * is not a tool result; feeding one in would write connection strings and + * query fragments into consumers built for row data. + * - **Operator bypass.** `_privacy_mode: bypass` is consent about a specific + * plugin's DECLARED output shape — an operator who decided masking mangles + * a tool's report did not thereby consent to arbitrary exception text, + * which can carry any row the driver happened to be holding. Emitting a + * `recordBypassedTool` receipt (with a byte count, as if a result had been + * disclosed) would also mis-describe what actually happened. + * + * What DOES transfer is the intern exemption — a self/infra tool's failure is + * the agent's own operational state, exactly the case the allowlist exists for + * — and `internToolResultV4` itself. Those two run here, in that order. + * + * Fail-OPEN on a masking throw, matching `afterDispatch` and the chat path. + * That is safe for the public endpoint and only for a structural reason: the + * fail-closed gate in `publicMcpPrivacy.ts` never lets `internToolResultV4` + * throw (it records the failure and returns a placeholder), and + * `PublicMcpServer` refuses any result the gate did not mask. Do not read this + * branch as "raw error text may reach an untrusted caller". + */ + private async maskErrorText(name: string, message: string): Promise { + const privacy = this.privacyHandle(); + // No privacy provider installed ⇒ results flow through unchanged here too, + // matching `afterDispatch`. The public endpoint refuses to call at all in + // this configuration (`requirePrivacyMasking`). + if (privacy === undefined) return message; + if (isInternExemptTool(name)) return message; + + try { + const v4 = await privacy.internToolResultV4({ + toolName: name, + rawResult: message, + }); + return v4.digestText; + } catch (err) { + console.warn( + `[toolDispatchService:${name}] privacy.internToolResultV4 threw while masking an ERROR message — sending it raw:`, + err, + ); + return message; + } } listDispatchableToolSpecs(): readonly DispatchableToolSpec[] { @@ -135,7 +465,15 @@ export class ToolDispatchService { }); } - return Array.from(advertised.values()); + // W0-3 — sort by name so every consumer of this list (the loopback MCP + // server, the CLI bridge) advertises a byte-stable order. Both source + // iterations above are Map-ordered — plugin load order and `created_at` + // row order — which differ across machines and deploys. + // + // Collision resolution is NOT affected: which spec wins a duplicate name + // was already decided by the `advertised.has(...)` guard above (native + // tools first), and sorting only reorders the surviving entries. + return sortByToolName(Array.from(advertised.values())); } private errMsg(error: unknown): string { @@ -143,8 +481,35 @@ export class ToolDispatchService { } } -// SEAM (M2): kernel-tool branches (knowledge_graph, chat_participants, -// ask_user_choice, suggest_follow_ups, find_free_slots, book_meeting, -// read_attachment) and scoped-memory shadowing, plus privacy interning / -// trace capture, are intentionally NOT replicated here — see -// Orchestrator.dispatchToolInner. +// SEAM — divergence from `Orchestrator.dispatchToolInner` / +// `dispatchToolDeadlined`, kept current deliberately. +// +// CLOSED (#542 prerequisite): the privacy data-plane boundary — intern-exemption, +// operator bypass with its receipt entry, and `internToolResultV4` masking — plus +// raw-result capture, now run on this path in the same order as the chat path. A +// caller reaching tools here no longer bypasses the PII masking chat enforces. +// Caller identity is carried by `ToolDispatchCallerContext` (a carrier, not an +// enforcement point — see its docs). +// +// CLOSED (W4): the ERROR path. A thrown handler's message used to skip the whole +// boundary above; it now goes through `maskErrorText` (intern-exemption + +// `internToolResultV4`, deliberately WITHOUT raw capture or the operator bypass — +// see that method for why). This is a DIVERGENCE from the chat path, which still +// lets a handler's exception propagate to the turn loop unmasked, and it is +// intentional: the chat path's reader is the operator, this path's reader may be +// a third party over HTTP. Every result now also carries `origin`, so a consumer +// can tell handler-authored content (must have been masked) from this service's +// own refusal strings (nothing to mask). +// +// STILL ORCHESTRATOR-ONLY, because each needs turn-scoped state this path has no +// access to (an unconditional copy would throw or silently no-op): +// - kernel-tool branches: scoped-memory shadowing, knowledge_graph, +// query_dataset, chat_participants, ask_user_choice, suggest_follow_ups, +// read_attachment, find_free_slots, book_meeting +// - `v4_*` verb/render tool routing via `privacy.runV4Tool` (needs the turn's +// data-plane engine; here such a name resolves to "unknown tool") +// - sub-agent dataset bridging (`subAgentDatasetSink` / `subAgentResultV4`) +// and the Slice-2.5 sub-agent bypass flag +// - MCP → Knowledge-Graph ingestion (needs `knowledgeGraph` + turn user id) +// - canvas sentinel tap (`canvasSentinelSink`) +// - the W0-2 per-tool dispatch deadline and its late-result firewall diff --git a/middleware/packages/harness-orchestrator/src/toolIdempotency.ts b/middleware/packages/harness-orchestrator/src/toolIdempotency.ts new file mode 100644 index 00000000..824cbc42 --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/toolIdempotency.ts @@ -0,0 +1,317 @@ +/** + * Idempotency for write-capable tool dispatch (#542 prerequisite). + * + * ## The problem + * + * Two independent retry layers can each execute one logical tool call twice: + * + * 1. **Transport retry** — `McpManager.callTool` retries ONCE on a transient + * transport failure (a deliberate, shipped mitigation for a flaky hosted + * proxy). A timeout or dropped connection is indistinguishable from "the + * server executed the write and the response was lost", so attempt 2 can + * re-execute a mutation that already happened. + * 2. **Caller retry** — an MCP client (or any HTTP caller of a future public + * endpoint) re-sends `tools/call` after a timeout. The dispatch layer sees + * two separate dispatches. + * + * For a read tool both are harmless. For a write tool (`Odoo`, `M365`) a + * duplicate is customer-data damage, which is why this exists before write tools + * are exposed publicly. + * + * ## What this module provides + * + * - {@link ToolIdempotencyStore} — a process-local dedupe cache that collapses + * layer 2: the first dispatch under a given key executes, later dispatches + * under the SAME key replay the first result without re-executing. + * - {@link runWithIdempotencyScope} / {@link currentIdempotencyScope} — an + * `AsyncLocalStorage` channel that carries the active key DOWN to layer 1 + * without touching `NativeToolHandler`. That contract + * (`(input: unknown) => Promise`) is published and implemented by + * out-of-tree plugins, so threading a key through it as a parameter is not an + * option; ambient propagation is the same trick the privacy handle already + * uses via `turnContext`. + * + * ## What the guarantee actually is — and is NOT + * + * GUARANTEED, within one process: + * - Two dispatches with the same `(idempotencyKey, toolName)` execute the + * underlying handler at most once while the first entry is live (bounded by + * {@link DEFAULT_IDEMPOTENCY_TTL_MS} and {@link DEFAULT_IDEMPOTENCY_MAX_ENTRIES}). + * - Concurrent duplicates collapse onto one in-flight execution rather than + * racing. + * - A replay carrying a DIFFERENT payload under an already-used key is + * rejected as a conflict instead of executing (see {@link idempotencyConflictMessage}). + * + * NOT GUARANTEED — do not read this as distributed idempotency: + * - **Process-local only.** The cache is an in-memory `Map`. Two middleware + * instances behind a load balancer, or a restart between the original call + * and the retry, will BOTH execute. Making this distributed requires a + * shared store (Postgres/Redis) keyed the same way; the key composition here + * is deliberately serialisable so that swap is additive. + * - **Bounded, not permanent.** After TTL expiry or LRU eviction a replayed + * key executes again. + * - **Failures are not cached.** See {@link ToolIdempotencyStore.run} — an + * errored call leaves no entry, so a caller retry re-executes it. This is a + * deliberate trade (a cached failure would make a legitimate retry + * impossible) and it means "exactly once" holds for SUCCEEDING calls; for a + * call that failed mid-flight the protection that applies is the layer-1 + * retry suppression in `McpManager.callTool`, not this cache. + * - **No remote enforcement.** The key is advertised to MCP servers in + * `_meta.idempotencyKey` so a server that implements dedupe can use it, but + * MCP defines no standard idempotency field and no server is required to + * honour it. Never treat propagation as protection. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { createHash } from 'node:crypto'; + +/** How long a completed idempotency record stays replayable. */ +export const DEFAULT_IDEMPOTENCY_TTL_MS = 15 * 60 * 1000; + +/** Hard cap on retained records; oldest-inserted is evicted first. */ +export const DEFAULT_IDEMPOTENCY_MAX_ENTRIES = 1000; + +/** + * The active idempotency scope, readable by any layer below the dispatcher. + * + * `exactlyOnce` is the ONLY signal `McpManager.callTool` uses to suppress its + * transient retry. It is set by the dispatcher exclusively for write-capable + * tools, so read tools keep the flaky-proxy retry mitigation unchanged. + */ +export interface ToolIdempotencyScope { + readonly key: string; + readonly toolName: string; + /** + * `true` ⇒ the caller asked for at-most-once execution of a WRITE-capable + * tool. Layers that cannot distinguish "failed before executing" from "failed + * after executing" must not retry under this flag. + */ + readonly exactlyOnce: boolean; +} + +const scopeStorage = new AsyncLocalStorage(); + +/** The idempotency scope of the in-flight dispatch, if any. */ +export function currentIdempotencyScope(): ToolIdempotencyScope | undefined { + return scopeStorage.getStore(); +} + +/** Run `fn` with `scope` visible to every layer beneath it. */ +export function runWithIdempotencyScope( + scope: ToolIdempotencyScope, + fn: () => T, +): T { + return scopeStorage.run(scope, fn); +} + +/** The stored outcome of a deduplicated dispatch. */ +export interface ToolIdempotencyResult { + readonly content: string; + readonly isError?: boolean; +} + +/** Outcome of a {@link ToolIdempotencyStore.run} call. */ +export interface ToolIdempotencyOutcome { + readonly result: ToolIdempotencyResult; + /** `true` when `result` came from the cache and the executor never ran. */ + readonly replayed: boolean; +} + +interface StoredEntry { + readonly fingerprint: string; + readonly storedAt: number; + readonly inFlight?: Promise; + readonly result?: ToolIdempotencyResult; +} + +/** Stable JSON: object keys sorted at every depth so `{a,b}` and `{b,a}` hash equal. */ +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`); + return `{${entries.join(',')}}`; +} + +/** Payload fingerprint used to detect same-key-different-body conflicts. */ +export function fingerprintToolInput(input: unknown): string { + return createHash('sha256').update(stableStringify(input)).digest('hex'); +} + +/** + * Cache key for a `(key, toolName)` pair. + * + * Length-prefixed on the tool name so the two components cannot be confused by a + * caller-supplied key that happens to contain the separator — `("a:b", "t")` and + * `("b", "t:a")` must not collide. Plain ASCII and deliberately serialisable, so + * a future distributed store can reuse this exact composition. + */ +export function idempotencyCacheKey(key: string, toolName: string): string { + return `${String(toolName.length)}:${toolName}:${key}`; +} + +/** + * The error a caller gets when it reuses a key with a different payload. Echoing + * the tool name (not the payload) keeps the message safe to surface. + */ +export function idempotencyConflictMessage(toolName: string): string { + return `Error: idempotency key reused for tool \`${toolName}\` with a different payload — refusing to execute. Use a fresh key for a different request.`; +} + +/** + * Process-local dedupe cache for write-capable tool dispatch. + * + * See the module header for the precise scope of the guarantee. In particular + * this is NOT distributed idempotency. + */ +export class ToolIdempotencyStore { + private readonly entries = new Map(); + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + + constructor(options?: { + readonly ttlMs?: number; + readonly maxEntries?: number; + /** Injectable clock — TTL expiry is tested without real waiting. */ + readonly now?: () => number; + }) { + this.ttlMs = options?.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS; + this.maxEntries = options?.maxEntries ?? DEFAULT_IDEMPOTENCY_MAX_ENTRIES; + this.now = options?.now ?? Date.now; + } + + /** + * Execute `exec` at most once per `(key, toolName)` while the entry is live. + * + * - Live completed entry, same payload ⇒ replay it, `exec` is NOT called. + * - Live entry, DIFFERENT payload ⇒ conflict error, `exec` is NOT called. + * - Live in-flight entry, same payload ⇒ await the original execution. + * - No live entry ⇒ run `exec` and store the result. + * + * A rejected or `isError` outcome is NOT retained (see module header). + */ + async run( + key: string, + toolName: string, + input: unknown, + exec: () => Promise, + ): Promise { + const cacheKey = idempotencyCacheKey(key, toolName); + const fingerprint = fingerprintToolInput(input); + const existing = this.live(cacheKey); + + if (existing !== undefined) { + if (existing.fingerprint !== fingerprint) { + return { + result: { content: idempotencyConflictMessage(toolName), isError: true }, + replayed: true, + }; + } + if (existing.result !== undefined) { + return { result: existing.result, replayed: true }; + } + if (existing.inFlight !== undefined) { + // Collapse a concurrent duplicate onto the original execution. If the + // original rejects, this duplicate must surface that rejection too — + // it never got its own execution. + return { result: await existing.inFlight, replayed: true }; + } + } + + const inFlight = exec(); + this.entries.set(cacheKey, { fingerprint, storedAt: this.now(), inFlight }); + // Bound the map on the IN-FLIGHT path too. Eviction used to run only after a + // successful completion, so a burst of dispatches that were all still + // running (or a handler that never settles) grew the map past `maxEntries` + // with nothing ever calling the evictor. + this.evictOverflow(); + // A duplicate awaiting `inFlight` handles its own rejection; this guard only + // stops an unobserved rejection from killing the process. + inFlight.catch(() => undefined); + + // Every write below is conditional on OUR entry still being the one in the + // map. Once an in-flight entry can expire or be evicted (see `live`), a + // later dispatch may legitimately have installed its own execution under + // this key while this one was still running; a blind `set`/`delete` here + // would clobber that newer entry with this older execution's outcome. + const stillOurs = (): boolean => this.entries.get(cacheKey)?.inFlight === inFlight; + + let result: ToolIdempotencyResult; + try { + result = await inFlight; + } catch (error) { + // Not retained — a caller retry after a thrown failure is allowed to run. + if (stillOurs()) this.entries.delete(cacheKey); + throw error; + } + if (result.isError === true) { + if (stillOurs()) this.entries.delete(cacheKey); + return { result, replayed: false }; + } + if (stillOurs()) { + this.entries.set(cacheKey, { fingerprint, storedAt: this.now(), result }); + this.evictOverflow(); + } + return { result, replayed: false }; + } + + /** + * `true` for an entry whose execution is still running AND still within the + * window in which it is worth collapsing duplicates onto. + * + * An in-flight entry used to be exempt from expiry AND from eviction with no + * upper bound at all, on the reasoning that it "never expires out from under + * its own execution". That reasoning holds only while the execution actually + * finishes: a handler that hangs forever (the exact failure the dispatch + * deadline exists for — and the deadline resolves the SLOT, it does not make + * this promise settle) pinned its key permanently, made every later call under + * that key wait on a promise that never resolves, and made the entry + * un-evictable, so the map grew past `maxEntries` unchecked. Past the TTL an + * in-flight entry is therefore treated exactly like a stale completed one. + */ + private isLiveInFlight(entry: StoredEntry): boolean { + if (entry.inFlight === undefined || entry.result !== undefined) return false; + return this.now() - entry.storedAt < this.ttlMs; + } + + /** Entry for `cacheKey` if it exists and has not expired; prunes on expiry. */ + private live(cacheKey: string): StoredEntry | undefined { + const entry = this.entries.get(cacheKey); + if (entry === undefined) return undefined; + // A still-running execution inside its TTL: collapse duplicates onto it. + if (this.isLiveInFlight(entry)) return entry; + if (this.now() - entry.storedAt >= this.ttlMs) { + // Also covers an in-flight entry past its TTL. Deleting the map entry does + // NOT cancel the underlying execution (nothing here can) — it stops a + // hung one from blocking every future call under this key, which is the + // difference between a stuck tool and a stuck key. + this.entries.delete(cacheKey); + return undefined; + } + return entry; + } + + /** Insertion-order eviction. Never drops an execution that is still live — + * but an in-flight entry past its TTL is no longer live (see + * {@link isLiveInFlight}) and is evictable like any other stale row. */ + private evictOverflow(): void { + while (this.entries.size > this.maxEntries) { + let evicted = false; + for (const [k, v] of this.entries) { + if (this.isLiveInFlight(v)) continue; + this.entries.delete(k); + evicted = true; + break; + } + if (!evicted) return; + } + } + + /** Retained record count — test/observability aid. */ + size(): number { + return this.entries.size; + } +} diff --git a/middleware/packages/harness-orchestrator/src/toolOrdering.ts b/middleware/packages/harness-orchestrator/src/toolOrdering.ts new file mode 100644 index 00000000..8f6bef0d --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/toolOrdering.ts @@ -0,0 +1,81 @@ +/** + * Deterministic tool ordering (W0-3). + * + * Anthropic prompt caching keys on a byte-exact prefix: `buildToolsList()` + * stamps `cache_control: { type: 'ephemeral' }` on the LAST tool spec, which + * makes the whole tool block one cacheable chunk. That only pays off if the + * block serializes identically every time. + * + * Several of the segments feeding that block are iterated straight out of a + * `Map`, so their order is insertion order: plugin load order for the native + * tool registry, `created_at` row order for domain tools. Both are stable + * *within* one process but diverge across Fly machines and across deploys — + * silently dropping the cache for the entire tool block and everything after + * it, with no error and no signal other than the cache-read token counter. + * + * Sorting the dynamic segments by name makes the serialized block a pure + * function of the tool SET rather than of registration timing. + * + * Ordering is advertisement-only. Collision resolution (native tools win over + * domain tools on a duplicate name) is decided by `Map` insertion in + * `ToolDispatchService`, never by array position, so sorting the resulting + * array cannot change which handler a name resolves to. + */ + +/** + * Locale-pinned name comparison. The explicit `'en'` locale keeps the result + * independent of the host's `LANG`/`LC_COLLATE`, which is the whole point — + * two Fly machines with different environments must produce the same bytes. + */ +export function compareToolNames(left: string, right: string): number { + return left.localeCompare(right, 'en'); +} + +/** Returns a new array sorted by `name`; never mutates the input. */ +export function sortByToolName( + items: readonly T[], +): T[] { + return [...items].sort((a, b) => compareToolNames(a.name, b.name)); +} + +/** + * Same ordering for tool shapes that carry their name on a nested `spec` + * (`LocalSubAgentTool` has no top-level `name`). + */ +export function sortBySpecName< + T extends { readonly spec: { readonly name: string } }, +>(items: readonly T[]): T[] { + return [...items].sort((a, b) => compareToolNames(a.spec.name, b.spec.name)); +} + +/** + * Normalizes the order of an MCP server's discovered-tool list before it is + * persisted to `mcp_servers.discovered_tools`. + * + * A server is free to return `tools/list` in any order it likes, and some + * return a different order per call. Without normalization each rediscovery + * rewrites the JSONB column with semantically identical content, which churns + * the row and any grant-epoch diff computed from it. + * + * Entries without a usable string `name` keep their relative order and are + * placed after named entries, so a malformed payload degrades rather than + * throws. + */ +export function normalizeDiscoveredToolOrder( + tools: readonly unknown[], +): unknown[] { + const named: Array<{ name: string; value: unknown }> = []; + const unnamed: unknown[] = []; + + for (const tool of tools) { + const name = + typeof tool === 'object' && tool !== null && 'name' in tool + ? (tool as { name: unknown }).name + : undefined; + if (typeof name === 'string') named.push({ name, value: tool }); + else unnamed.push(tool); + } + + named.sort((a, b) => compareToolNames(a.name, b.name)); + return [...named.map((entry) => entry.value), ...unnamed]; +} diff --git a/middleware/packages/harness-orchestrator/src/tools/domainQueryTool.ts b/middleware/packages/harness-orchestrator/src/tools/domainQueryTool.ts index 35f3498a..6ade52ce 100644 --- a/middleware/packages/harness-orchestrator/src/tools/domainQueryTool.ts +++ b/middleware/packages/harness-orchestrator/src/tools/domainQueryTool.ts @@ -77,7 +77,7 @@ export interface Askable { * orchestrator threads it onto every emitted `ReadonlyToolTraceEntry` so * the Nudge-Pipeline's multi-domain trigger can count distinct domains. */ -import type { ToolPIIField } from '@omadia/plugin-api'; +import type { ToolPIIField, WriteCapability } from '@omadia/plugin-api'; export interface DomainTool { name: string; @@ -119,6 +119,17 @@ export interface DomainTool { * See `@omadia/plugin-api`'s `piiAnnotation.ts` for the full schema. */ piiFields?: readonly ToolPIIField[]; + /** + * #542 prerequisite — declared write capabilities, i.e. "dispatching me may + * MUTATE data". Same contract and same rationale as + * `NativeToolRegistration.writeCapabilities`: it lives on the wrapper rather + * than on `spec` because Anthropic rejects unknown fields on a tool spec, so + * mutability is a harness-side concern exactly like `piiFields` above. + * + * Read by `ToolDispatchService` to decide whether a dispatch needs + * at-most-once protection. Absent or empty ⇒ treated as read-only. + */ + writeCapabilities?: readonly WriteCapability[]; } export interface DomainToolSpec { diff --git a/middleware/packages/harness-orchestrator/src/turnContext.ts b/middleware/packages/harness-orchestrator/src/turnContext.ts index 30781bff..f428f799 100644 --- a/middleware/packages/harness-orchestrator/src/turnContext.ts +++ b/middleware/packages/harness-orchestrator/src/turnContext.ts @@ -68,6 +68,19 @@ export interface TurnContextValue { * back to the raw `userId` for an ACL decision. */ resolvedOmadiaUserId?: string; + /** + * W2-1 (#544) — the turn's session scope (`input.sessionScope`, falling back + * to the turn id when the caller supplied none), as computed once by the + * orchestrator entry point. + * + * Exists so the MCP manager can key a parked `input_required` record on + * `{userId, sessionId, correlationId}` without a call-site parameter sweep. + * NOT safe as a key on its own: `resolveScope` returns the literal + * `'http-default'` for unscoped HTTP turns, so every such caller shares this + * value — that was the live cross-user hole in #445. It is one component of + * the triple, never the whole key. + */ + sessionScope?: string; chatParticipants?: ChatParticipantsProvider; /** * Privacy-Proxy Slice 2.1: per-turn privacy handle threaded through the @@ -174,6 +187,22 @@ export interface TurnContextValue { * after persona routing, mutated on the live store so nested scopes see it. */ activePersonaSkillId?: string; + /** + * W2-1 (#544) — outcome of an MCP `input_required` REPLAY performed at turn + * start, as a note to append to the user's wire message so the model can + * narrate the result in this same turn. + * + * Rides the turn context rather than a parameter for the same reason + * `privacyHandle` does: it would otherwise need threading through + * `runTurn → chatInContext → chatInContextInner` and the streaming mirror of + * all three, on both of which every call site already passes `input`. Written + * onto the LIVE store inside the turn scope (same technique as + * `activePersonaSkillId`), read once when the wire messages are assembled. + * + * Carries no collected values — those may be secrets the user typed for the + * server, and this string reaches the LLM and the session log. + */ + mcpInputReplayNote?: string; } const storage = new AsyncLocalStorage(); @@ -185,13 +214,39 @@ export const turnContext = { }, /** * Sets the turn context for the current async resource and its descendants. - * Used from async generators (`chatStream`) because AsyncLocalStorage.run() - * doesn't compose with `yield`. Scope is bounded by the enclosing HTTP - * request — a new request creates a fresh async resource chain. + * + * ⚠️ NOT usable from an async generator. `enterWith` binds the store to the + * async resource that is executing at that instant, but a generator is + * resumed in the async context of whoever called `.next()` — so the store is + * gone the moment the generator yields, and every continuation after that + * point sees either nothing or the CONSUMER's ambient scope. The streaming + * orchestrator entry point used to do exactly this, which silently broke MCP + * audit attribution (`callerKind`/`turnId`/`mcpUserKey`) on every streaming + * turn. Use {@link runGenerator} from generators. + * + * Correct uses are plain async functions whose own async chain bounds the + * scope — e.g. an Express route handler establishing a per-request identity. */ enter(value: TurnContextValue): void { storage.enterWith(value); }, + /** + * Establishes `value` for the entire lifetime of an async generator — the + * `run()` equivalent that composes with `yield`. + * + * Every advance of the inner generator is performed inside `storage.run`, so + * the context is active for exactly the spans that execute generator body + * code, and is NOT active while the consumer processes a yielded value. + * `value` is passed by reference on every step, so writes onto the live store + * (`activePersonaSkillId`, `mcpInputReplayNote`) stay visible to later steps + * — same semantics `run()` gives a plain async function. + */ + runGenerator( + value: TurnContextValue, + makeGenerator: () => AsyncGenerator, + ): AsyncGenerator { + return runGeneratorInContext(value, makeGenerator); + }, /** * Runs `fn` in an outer scope that only installs a `chatParticipants` * provider — turnId/turnDate are left as placeholders the orchestrator @@ -210,6 +265,10 @@ export const turnContext = { turnDate: prev?.turnDate ?? today(), ...(prev?.agentSlug ? { agentSlug: prev.agentSlug } : {}), chatParticipants, + // W3-A: the caller identity MCP OAuth tokens are keyed to must survive + // an adapter-established outer scope, or every audited MCP call on a + // channel turn records `unresolved` and a `per_user` server fails closed. + ...(prev?.mcpUserKey ? { mcpUserKey: prev.mcpUserKey } : {}), ...(prev?.privacyHandle ? { privacyHandle: prev.privacyHandle } : {}), ...(prev?.captureRawToolResult ? { captureRawToolResult: prev.captureRawToolResult } @@ -246,6 +305,95 @@ export const turnContext = { }, }; +/** + * Implementation of {@link turnContext.runGenerator}. Kept as a module-level + * generator function (rather than inline) so it can `yield` while still owning + * the `storage.run` wrapping of every `next()`. + */ +async function* runGeneratorInContext( + value: TurnContextValue, + makeGenerator: () => AsyncGenerator, +): AsyncGenerator { + // Create inside the scope too: a factory that reads the context eagerly + // (before its first yield) then behaves the same as one that reads it later. + const inner = storage.run(value, makeGenerator); + let exhausted = false; + try { + for (;;) { + const step = await storage.run(value, () => inner.next()); + if (step.done) { + exhausted = true; + return; + } + yield step.value; + } + } finally { + // The consumer broke out of its loop or threw. Drive the inner generator's + // own `finally` blocks (steering-bus teardown, privacy finalisation) INSIDE + // the turn scope — outside it they would run context-less, which is the + // very bug this helper exists to prevent. + // + // Teardown NEVER replaces the exit reason. `inner.return(undefined)` was + // awaited bare, and a throwing finaliser (privacy finalisation is the + // realistic one) inside a `finally` block REPLACES the completion of the + // whole generator: the client abort or upstream error that actually ended + // the turn was overwritten by a secondary teardown failure, and the real + // reason — the one worth debugging — was gone. So the teardown failure is + // caught and reported here instead of being allowed to propagate. It is not + // swallowed: {@link onTurnTeardownError} always sees it, and the caller + // still gets the original reason. + if (!exhausted) { + try { + await storage.run(value, async () => { + await inner.return(undefined); + }); + } catch (err: unknown) { + reportTurnTeardownError(value.turnId, err); + } + } + } +} + +/** Handler for a teardown failure. Overridable so a test can assert the error + * is surfaced rather than inferring it from console output. */ +let turnTeardownErrorHandler: (turnId: string, err: unknown) => void = ( + turnId, + err, +) => { + console.error( + `[turnContext] teardown of turn '${turnId}' threw while finalising ` + + `(steering-bus teardown / privacy finalisation). The turn's original exit ` + + `reason was preserved and is what the caller sees; this is the secondary ` + + `failure:`, + err, + ); +}; + +/** + * Install a teardown-error handler. Returns a restore function. + * + * Exists because a teardown failure is deliberately not thrown (see + * `runGeneratorInContext`), so without a hook the only evidence would be a log + * line — which is neither assertable nor routable to real error reporting. + */ +export function onTurnTeardownError( + handler: (turnId: string, err: unknown) => void, +): () => void { + const previous = turnTeardownErrorHandler; + turnTeardownErrorHandler = handler; + return (): void => { + turnTeardownErrorHandler = previous; + }; +} + +function reportTurnTeardownError(turnId: string, err: unknown): void { + try { + turnTeardownErrorHandler(turnId, err); + } catch { + /* a reporter that throws must not become the exit reason either */ + } +} + /** `YYYY-MM-DD` in Europe/Berlin. Single place this computation lives. */ export function today(): string { return new Intl.DateTimeFormat('en-CA', { diff --git a/middleware/packages/llm-provider/src/modelRegistry.ts b/middleware/packages/llm-provider/src/modelRegistry.ts index 646d7986..494d924b 100644 Binary files a/middleware/packages/llm-provider/src/modelRegistry.ts and b/middleware/packages/llm-provider/src/modelRegistry.ts differ diff --git a/middleware/packages/plugin-api/src/agentGraph.ts b/middleware/packages/plugin-api/src/agentGraph.ts index 08c1f4d9..8d0a52fe 100644 --- a/middleware/packages/plugin-api/src/agentGraph.ts +++ b/middleware/packages/plugin-api/src/agentGraph.ts @@ -106,6 +106,10 @@ export interface McpDiscoveredTool { readonly name: string; readonly description?: string; readonly inputSchema?: Record; + /** Issue #547 (W1-3) — the tool's declared `outputSchema`, captured at + * discovery. Persisted alongside the rest of the descriptor so it survives + * a restart without re-discovery. Never shown to the model. */ + readonly outputSchema?: Record; } export interface McpServerNode { diff --git a/middleware/packages/plugin-api/src/pluginContext.ts b/middleware/packages/plugin-api/src/pluginContext.ts index 7db19cd3..79def2c0 100644 --- a/middleware/packages/plugin-api/src/pluginContext.ts +++ b/middleware/packages/plugin-api/src/pluginContext.ts @@ -17,6 +17,8 @@ import type { Socket } from 'node:net'; +import type { WriteCapability } from './writeCapabilities.js'; + import type { EntityCapturedTurnsHit, EntityCapturedTurnsOptions, @@ -586,6 +588,23 @@ export interface ToolRegistrationOptions { readonly promptDoc?: string; /** Per-turn attachment collector. See NativeToolAttachmentSink docs. */ readonly attachmentSink?: NativeToolAttachmentSink; + /** + * #542 prerequisite — declare that dispatching this tool may MUTATE data. + * + * This is the plugin-facing end of the `WriteCapability` contract in + * `./writeCapabilities.ts` (see the NOTE under `NativeToolSpec` for why it + * rides the options bag rather than the spec: the spec is forwarded verbatim + * to Anthropic, which rejects unknown fields). The kernel stores it on the + * registry entry, where `ToolDispatchService` reads it. + * + * Declaring it opts the tool into duplicate-write protection: a dispatch that + * carries an idempotency key is deduplicated, and the MCP transport's + * transient retry is suppressed for it (a retry cannot tell "failed before + * writing" from "wrote, then lost the response"). A tool that mutates data and + * omits this gets no such protection — for an Odoo or M365 write reachable from + * a public endpoint, that means a duplicate is possible. + */ + readonly writeCapabilities?: readonly WriteCapability[]; } /** diff --git a/middleware/packages/plugin-api/src/writeCapabilities.ts b/middleware/packages/plugin-api/src/writeCapabilities.ts index 4da6ba0b..d0f9b597 100644 --- a/middleware/packages/plugin-api/src/writeCapabilities.ts +++ b/middleware/packages/plugin-api/src/writeCapabilities.ts @@ -49,6 +49,29 @@ export interface WriteCapability { }; } +/** + * True when a tool declares at least one write capability — i.e. dispatching it + * may MUTATE data in a downstream system. + * + * This is the single predicate the dispatch layer uses to decide whether a call + * needs at-most-once protection (idempotency dedupe + transport-retry + * suppression). It is deliberately declaration-driven rather than name-derived: + * guessing "write" from a free-form tool name is exactly the silent-rollback + * failure mode the `WriteCapability` contract exists to avoid. + * + * A tool that declares NOTHING is treated as read-only. That default is safe for + * the canvas/inline-edit consumer (no affordances offered) but note the opposite + * asymmetry here: an UNANNOTATED write tool gets no idempotency protection. The + * annotation is the only signal available without an LLM call, so a plugin that + * mutates data and ships no `writeCapabilities` is a plugin bug — see the + * `writeCapabilities` field docs on `NativeToolRegistration`. + */ +export function isWriteCapableTool( + capabilities: readonly WriteCapability[] | undefined, +): boolean { + return capabilities !== undefined && capabilities.length > 0; +} + /** Deterministic Tier-2 derivation of mutability from a tool's write capabilities. */ export interface DerivedMutability { canAddItems: boolean; diff --git a/middleware/src/agents/subAgentToolHydration.ts b/middleware/src/agents/subAgentToolHydration.ts index 3befd998..f6ed9db9 100644 --- a/middleware/src/agents/subAgentToolHydration.ts +++ b/middleware/src/agents/subAgentToolHydration.ts @@ -19,11 +19,13 @@ import { import type { LocalSubAgentTool } from '@omadia/plugin-api'; import { buildSubAgentDomainTools, + createLongRunningSubAgentTool, mcpNativeHandler, mcpToolNameFromRef, mcpToolToNativeSpec, turnContext, type DomainTool, + type TaskStore, type DomainToolSpec, type McpConfigField, type McpManager, @@ -71,6 +73,22 @@ export interface HydrateDeps { readonly personaSkills?: readonly SkillRow[]; /** Operator bindings for skill capability contracts (issue #456). */ readonly skillToolBindings?: readonly SkillToolBindingRow[]; + /** + * W2-2 (issue #543) — sub-agent tool names (`ask_`) that ALSO get the + * non-blocking `_start`/`_status`/`_list` triple, so a slow sub-agent + * stops blocking the chat turn it was delegated from. + * + * Opt-in per sub-agent, and deliberately NOT a DB column: a column needs a + * migration, and `0031`/`0032` are taken by parallel units. Sourced from + * `LONG_RUNNING_SUBAGENT_TOOLS` config so it is operator-settable today; the + * per-sub-agent DB flag is the follow-up that owns the migration. + * + * The blocking `ask_` tool stays registered either way — a sub-agent + * that answers in seconds is better inline, and the model picks by name. + */ + readonly longRunningSubAgentTools?: readonly string[]; + /** Shared store backing the deferred sub-agent tasks. Omit ⇒ feature off. */ + readonly taskStore?: TaskStore; readonly log?: (msg: string) => void; } @@ -184,7 +202,12 @@ export function adaptNativeToolForSubAgent( /** Resolve the discovered descriptor for a granted tool from the server row, * so the DomainTool spec carries the real description + inputSchema. Falls * back to a name-only descriptor (schema-less, still callable) when the - * server has not been re-discovered since the grant. */ + * server has not been re-discovered since the grant. + * + * Issue #547 (W1-3): also rehydrates the persisted `outputSchema`. That is + * what makes the schema survive a restart — discovery may not run again for + * the lifetime of the process, and the structured-result sidecar reads the + * schema from the descriptor the adapters seed it with. */ function discoveredDescriptor( row: McpServerRow | undefined, toolRef: string, @@ -198,11 +221,21 @@ function discoveredDescriptor( ...(hit['inputSchema'] && typeof hit['inputSchema'] === 'object' ? { inputSchema: hit['inputSchema'] as Record } : {}), + ...(isPlainObject(hit['outputSchema']) + ? { outputSchema: hit['outputSchema'] } + : {}), }; } return { name: toolRef }; } +/** True for a non-null, non-array object. Arrays are rejected: an + * `outputSchema` is a JSON-Schema object, and a persisted array would be + * corrupt data rather than a lenient variant. */ +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + /** * Adapt one top-level MCP tool grant into a per-agent DomainTool (epic #459 * W0, issue #457). Composes the previously-unwired adapters: spec via @@ -217,6 +250,11 @@ export function mcpGrantToDomainTool( cfg: McpServerConfig, descriptor: McpToolDescriptor, ): DomainTool { + // Issue #547 (W1-3): seed the manager's output-schema cache from this + // descriptor. `mcpNativeHandler` only closes over a tool NAME, so without + // this the sidecar would lose the schema on any process that never ran + // discovery itself (i.e. every restart). + manager.rememberToolSchema(cfg.id, descriptor); const spec = mcpToolToNativeSpec(cfg.name, descriptor); const handler = mcpNativeHandler(manager, cfg, descriptor.name); return { @@ -363,6 +401,10 @@ export function registerDbSubAgentTools( turnDate: current.turnDate, ...(current.agentSlug ? { agentSlug: current.agentSlug } : {}), ...(current.privacyHandle ? { privacyHandle: current.privacyHandle } : {}), + // W3-A — same carry-over as the plugin accessor: without it a + // skill-bound MCP call reaches a `per_user` server with no + // identity, audits as `unresolved` and fails closed. + ...(current.mcpUserKey ? { mcpUserKey: current.mcpUserKey } : {}), activePersonaSkillId: skillId, mcpCallerKind: 'skill', mcpCallerId: skillSlug, @@ -374,6 +416,49 @@ export function registerDbSubAgentTools( } } + // W2-2 (issue #543) — deferred sub-agent dispatch. For each opted-in + // sub-agent, register the non-blocking triple as NATIVE tools alongside the + // blocking `ask_` DomainTool. Additive: nothing above changes, and with + // no allowlist (or no store) this loop does not execute at all. + const deferredNames = new Set(deps.longRunningSubAgentTools ?? []); + if (deferredNames.size > 0 && deps.taskStore) { + const taskStore = deps.taskStore; + for (const tool of tools) { + if (!deferredNames.has(tool.name)) continue; + // Adapt the DomainTool back to an Askable rather than rebuilding the + // sub-agent: this reuses the EXACT dispatch path the blocking tool uses + // (same LocalSubAgent, same domain logging, same error wrapping), so the + // deferred and inline routes cannot drift apart. + const handle = createLongRunningSubAgentTool({ + baseToolName: tool.name, + displayName: tool.name.replace(/^ask_/, '') || tool.name, + description: tool.spec.description, + agent: { ask: (question, observer) => tool.handle({ question }, observer) }, + store: taskStore, + onRunnerError: (err, taskId) => { + deps.log?.( + `subAgentToolHydration: deferred ${tool.name} task ${taskId} runner failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }, + }); + for (const r of handle.registrations) { + if (deps.nativeToolRegistry.get(r.name)) { + deps.log?.( + `subAgentToolHydration: deferred tool "${r.name}" already registered — skipped`, + ); + continue; + } + deps.nativeToolRegistry.register(r.name, { + handler: r.handler, + spec: r.spec, + promptDoc: r.promptDoc, + }); + } + } + } + let n = 0; for (const t of tools) { if (!built.orchestrator.hasDomainTool(t.name)) { diff --git a/middleware/src/auth/publicPaths.ts b/middleware/src/auth/publicPaths.ts index 903c9a52..86cf03a1 100644 --- a/middleware/src/auth/publicPaths.ts +++ b/middleware/src/auth/publicPaths.ts @@ -12,6 +12,15 @@ * blanket guard. A shared constant makes that class of drift impossible. */ +import { CIMD_METADATA_PATH } from '../services/mcpCimd.js'; +import { PUBLIC_MCP_PATH } from '../mcp/publicMcpPath.js'; + +/** Escape a literal path for embedding in a RegExp, so the shared constant — + * not a hand-retyped pattern — is what the allowlist actually matches. */ +function pathPrefixPattern(path: string): RegExp { + return new RegExp(`^${path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:$|\\?)`); +} + /** Public paths that are constant regardless of configuration. */ export const STATIC_PUBLIC_PATHS: readonly RegExp[] = [ /^\/api\/v1\/auth(?:\/|$|\?)/, @@ -42,6 +51,15 @@ export const STATIC_PUBLIC_PATHS: readonly RegExp[] = [ // never added to this list when the feature shipped, so it 401'd before // ever reaching the handler that validates that state token. /^\/api\/v1\/operator\/mcp-oauth\/callback(?:\/|$|\?)/, + // W2-4 (issue #546) — the MCP Client ID Metadata Document. An authorization + // server fetches this with no credential of ours (the whole point: the + // `client_id` we hand it IS this URL, which it dereferences), so it must never + // sit behind an operator session. It carries no secret — only the redirect URI + // and a display name, both of which the IdP already sees during the authorize + // round-trip. Built from the SHARED `CIMD_METADATA_PATH` constant rather than + // a hand-written regex, for the reason in this module's doc comment above: + // the express route and this allowlist must be incapable of drifting. + pathPrefixPattern(CIMD_METADATA_PATH), // Plugin-served UI surfaces (`/p//...`), iframed by Teams where // only a Teams SSO token exists. Plugins exposing sensitive data validate // that token themselves. @@ -62,6 +80,20 @@ export const STATIC_PUBLIC_PATHS: readonly RegExp[] = [ // narrowest regex that covers the one route, never a prefix that also // catches its siblings. /^\/api\/public\/v1\/chat(?:\/|$|\?)/, + // W2-3 (issue #542) — the public, stateless MCP endpoint. Follows the NOTE + // directly above to the letter: built from the SHARED `PUBLIC_MCP_PATH` + // constant (so the express mount and this allowlist cannot drift), and via + // `pathPrefixPattern`, which anchors on `$` or `?` and therefore matches the + // ONE path — not `/api/v1/mcp/anything` and not a sibling like + // `/api/v1/mcp-servers`. No regex bypass, no prefix that catches neighbours. + // + // Its authentication is `requireApiKey` from `@omadia/api-key-auth`, mounted + // by `mcp/publicMcpRouter.ts`. That is necessary but NOT the whole gate: the + // key must additionally hold `mcp:list`/`mcp:invoke` (and the exact + // `mcp:write:` for a write), AND have an enabled + // `public_mcp_key_bindings` row naming the one agent and the exact tools it + // reaches. A key with no row authenticates and reaches nothing. + pathPrefixPattern(PUBLIC_MCP_PATH), ]; /** diff --git a/middleware/src/auth/sessionIdentity.ts b/middleware/src/auth/sessionIdentity.ts new file mode 100644 index 00000000..b4725f22 --- /dev/null +++ b/middleware/src/auth/sessionIdentity.ts @@ -0,0 +1,28 @@ +import type { Request } from 'express'; + +// Pulls in the `declare module 'express-serve-static-core'` augmentation that +// puts `session?: SessionClaims` on `Request`. Import-for-side-effect only — +// without it this module type-checks against a bare Express `Request`. +import './requireAuth.js'; + +/** + * The identity a request's SESSION offers, with no fallback baked in. + * + * This is the OAuth-shaped key the MCP token table is keyed on: the same value + * `/mcp-servers/:id/authorize` stores a token under, so a later chat turn from + * the same operator resolves that token again. It is deliberately NOT + * `req.session.omadia_user_id` (the KG cluster-root id that `chat.ts`'s + * `resolveUserId` reads) — those are different namespaces, and conflating them + * would look up a token that was never stored. + * + * W0-1 (D2): the old `|| 'operator'` tail is gone. Whether an unresolved + * identity may borrow a shared one is the server's `delegation` decision, + * applied by `resolveMcpUserKey` — never an implicit default here. Callers + * must treat `null` as "no identity" and must NOT invent a substitute. + * + * Extracted verbatim from `routes/agentBuilder.ts` (W4-1) so the chat routes + * can PRODUCE the key the MCP auth provider already CONSUMES. + */ +export function sessionIdentity(req: Request): string | null { + return req.session?.sub || req.session?.email || null; +} diff --git a/middleware/src/config.ts b/middleware/src/config.ts index de63ee5c..0a80525f 100644 --- a/middleware/src/config.ts +++ b/middleware/src/config.ts @@ -494,6 +494,29 @@ const ConfigSchema = z.object({ .positive() .default(15_000), + // --- Public MCP endpoint (W2-3, issue #542) ------------------------------ + // omadia's own tools, exposed over a stateless Streamable-HTTP MCP server at + // /api/v1/mcp to third parties holding an API key. This is the highest-blast + // -radius surface in the MCP cluster — an internet-facing route that reaches + // the tool layer, including WRITE tools by operator allowlist — so the whole + // thing is dark by default: false mounts NO router at all, which is a + // stronger guarantee than mounting one that answers 403. + PUBLIC_MCP_ENABLED: devFlag(), + // Whether tool calls are served when PII masking is unavailable. + // + // The dispatch privacy seam is closed — `ToolDispatchService` now replicates + // the chat path's data-plane boundary — but it closed it at PARITY, which + // includes two behaviours that are wrong for an untrusted caller: masking + // fails OPEN on a provider error, and no installed privacy provider means + // results pass through unchanged. The endpoint overrides both (see + // `mcp/publicMcpPrivacy.ts`); this flag is the escape hatch for the coarsest + // one — an install with no privacy provider at all. + // + // Default false ⇒ tools/list works, tools/call refuses and says why. + // Set true ONLY on a deliberate, documented operator decision — e.g. an + // install whose allowlisted tools provably carry no personal data. + PUBLIC_MCP_ALLOW_WITHOUT_PRIVACY_MASKING: devFlag(), + // --- Dev platform (epic #470 W0) ---------------------------------------- // Isolated per-job code runners (clone → agent-edit → diff → server-side PR). // The whole subsystem is dark by default: DEV_PLATFORM_ENABLED=false mounts @@ -512,6 +535,17 @@ const ConfigSchema = z.object({ .default(() => path.join(os.tmpdir(), 'omadia-dev-jobs')), // Where the runner phones home. Defaults to loopback + PORT in loadConfig. DEV_PLATFORM_RUNNER_BASE_URL: optionalNonEmpty(z.string().url()), + // W2-2 (issue #543) — comma-separated sub-agent tool names (`ask_`) that + // ALSO get the non-blocking `_start`/`_status`/`_list` triple, so a slow + // sub-agent stops blocking the chat turn it was delegated from. The blocking + // `ask_` tool stays registered either way; empty (the default) means every + // sub-agent keeps today's inline behaviour exactly. + LONG_RUNNING_SUBAGENT_TOOLS: z.string().default(''), + // Orphan windows for the long-running task seam: a live task silent this long + // is failed as abandoned, and a finished task is retained this long so a + // following turn can still collect its result. + LONG_RUNNING_TASK_STALE_MS: z.coerce.number().int().positive().default(900_000), + LONG_RUNNING_TASK_RETAIN_MS: z.coerce.number().int().positive().default(3_600_000), DEV_PLATFORM_CLI_BIN: z.string().min(1).default('claude'), DEV_PLATFORM_JOB_WALL_CLOCK_MS: z.coerce.number().int().positive().default(1_800_000), DEV_PLATFORM_HEARTBEAT_TIMEOUT_MS: z.coerce.number().int().positive().default(120_000), diff --git a/middleware/src/devplatform/devJobStore.ts b/middleware/src/devplatform/devJobStore.ts index f5d65aff..399bbd93 100644 --- a/middleware/src/devplatform/devJobStore.ts +++ b/middleware/src/devplatform/devJobStore.ts @@ -165,6 +165,25 @@ export interface DevJobStoreOptions { artifactCeiling?: ArtifactCeilingOptions; } +/** + * Narrows a reaper sweep (W3-A). `dev_jobs` has no tenant column — `repo_id` IS + * the tenancy axis here, and every suite/deployment owns its own `dev_repos` + * rows — so a repo-id set is the scope key. + * + * OMITTING this (the production call) keeps the sweep DATABASE-GLOBAL, which is + * correct for the single-tenant deployment: the reaper must reach every + * abandoned job whoever launched it. It exists because a global sweep is + * untestable in a shared cluster — a forward-dated cutoff in one suite finalized + * a sibling suite's in-flight jobs as `stalled`. + * + * An EXPLICITLY EMPTY `repoIds` means "nothing is in scope" and returns no rows. + * It must never widen back to global: that would turn a caller who computed an + * empty entitlement set into a caller who sweeps everything. + */ +export interface DevJobSweepScope { + readonly repoIds?: readonly string[]; +} + export interface ListJobsFilter { repoId?: string; /** Scope to a SET of repos IN SQL (before LIMIT). Use this — not a post-query @@ -876,13 +895,18 @@ export class DevJobStore { // --- reaper / enforcement reads (worker calls finalizeDevJob on these) ---- /** Active jobs whose last sign of life is older than `cutoff` — stalled - * candidates for the worker/reaper. */ - async findStalled(cutoff: Date): Promise { + * candidates for the worker/reaper. Unscoped ⇒ database-global (production); + * pass `scope` to constrain it. See {@link DevJobSweepScope}. */ + async findStalled(cutoff: Date, scope?: DevJobSweepScope): Promise { + // An explicitly empty scope means "nothing", never "everything". + if (scope?.repoIds !== undefined && scope.repoIds.length === 0) return []; + const scoped = scope?.repoIds !== undefined; const r = await this.pool.query( `SELECT ${JOB_COLS} FROM dev_jobs WHERE status IN (${ACTIVE_SET_SQL}) - AND COALESCE(last_heartbeat_at, started_at, claimed_at) < $1`, - [cutoff], + AND COALESCE(last_heartbeat_at, started_at, claimed_at) < $1 + ${scoped ? 'AND repo_id = ANY($2::uuid[])' : ''}`, + scoped ? [cutoff, scope.repoIds] : [cutoff], ); return r.rows.map(toJob); } diff --git a/middleware/src/devplatform/devJobTaskStore.ts b/middleware/src/devplatform/devJobTaskStore.ts new file mode 100644 index 00000000..2dc3c97a --- /dev/null +++ b/middleware/src/devplatform/devJobTaskStore.ts @@ -0,0 +1,367 @@ +/** + * W2-2 (issue #543, rescoped) — `dev_job` as the FIRST implementor of the + * generic long-running task seam (`@omadia/orchestrator`'s `TaskStore`). + * + * ## Why this is an adapter and not a rewrite + * + * The seam was extracted FROM `devJobStore` + `devJobOrchestratorTool`, so + * proving it is a faithful description of dev_job's semantics means driving + * dev_job's real claim/lease, event tail, and terminal transition THROUGH the + * seam's interface and getting identical behaviour. That is what this file does. + * + * It is deliberately ADDITIVE and INSERT-ONLY with respect to the dev platform: + * not one line of `devJobStore.ts` or `devJobOrchestratorTool.ts` changes, no + * migration is added, and `dev_job_start` / `dev_job_status` / `dev_job_list` + * keep their exact result contracts (`{"status":"job_started",…}` still parses in + * `web-ui/app/_components/devjobs/devJobChatCardState.ts`). There is open work + * extracting the dev platform into an installable plugin (PR #536 merged, #538 + * open); keeping this file separate means that extraction moves one new file + * rather than resolving conflicts inside two hot ones. + * + * ## Status projection + * + * dev_job's ten-value `DevJobStatus` projects DOWN onto the seam's four-value + * MCP-Tasks-shaped vocabulary. Nothing is lost that a caller needs: the precise + * dev_job status remains readable via `dev_job_status`, and `TaskDescriptor.phase` + * carries the pipeline phase. + * + * queued | provisioning | running | applying -> working + * waiting -> input_required + * done -> completed + * failed | cancelled | stalled | budget_exceeded -> failed + * + * `cancelled` maps to `failed` rather than `completed` because from the caller's + * point of view the work did not produce its result — the seam's vocabulary has + * no "cancelled", and reporting a cancel as `completed` would make a poll claim + * a result that does not exist. + * + * ## The one intentional divergence: `finish` fencing + * + * The seam's `finish` is lease-fenced. dev_job's `finishTerminal` deliberately is + * NOT — "cancel routes and the reaper legitimately finalize jobs they do not + * lease" (`devJobStore.ts`). This adapter honours BOTH: a write is accepted when + * the presented lease matches, OR when the job currently holds no lease at all + * (the administrative/reaper case). A write presenting a lease that does not + * match a lease the job DOES hold is rejected with `TaskLeaseLostError` — which + * is the property the fence exists for, and the one the conformance test pins. + */ + +import { + TaskLeaseLostError, + TASK_LEASE_UUID_RE, + type NewTaskInput, + type TaskDescriptor, + type TaskEventRecord, + type TaskListFilter, + type TaskLifecycleStatus, + type TaskReapOptions, + type TaskReapResult, + type TaskStore, + type TerminalTaskPatch, +} from '@omadia/orchestrator'; + +import type { DevJobSweepScope, ListJobsFilter } from './devJobStore.js'; +import type { + DevJob, + DevJobEvent, + DevJobStatus, + NewDevJob, +} from './types.js'; + +// --------------------------------------------------------------------------- +// Projection. +// --------------------------------------------------------------------------- + +/** dev_job status -> seam status. Exhaustive by construction: the `Record` key + * type is `DevJobStatus`, so adding a status to the union fails the build here + * until it is projected, instead of silently defaulting to something wrong. */ +const STATUS_PROJECTION: Record = { + queued: 'working', + provisioning: 'working', + running: 'working', + applying: 'working', + waiting: 'input_required', + done: 'completed', + failed: 'failed', + cancelled: 'failed', + stalled: 'failed', + budget_exceeded: 'failed', +}; + +export function projectDevJobStatus(status: DevJobStatus): TaskLifecycleStatus { + return STATUS_PROJECTION[status]; +} + +/** + * dev_job -> TaskDescriptor. + * + * `result` is rendered as compact JSON of the dev_job result (outcome + refs), + * NOT the diff or transcript: those live in artifacts behind an authorized route, + * and copying them here would put unbounded, PII-bearing content on a poll path. + */ +export function toTaskDescriptor(job: DevJob): TaskDescriptor { + return { + id: job.id, + kind: job.kind, + status: projectDevJobStatus(job.status), + phase: job.phase, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + endedAt: job.endedAt, + claimedBy: job.claimedBy, + lastHeartbeatAt: job.lastHeartbeatAt, + result: job.result ? JSON.stringify(job.result) : null, + error: job.error, + }; +} + +/** dev_job event -> seam event. `id` is the seam's `seq`: it is the monotonic + * IDENTITY column dev_job's own SSE uses, whereas dev_job's `seq` restarts per + * provision and would go backwards across a re-provision. */ +export function toTaskEvent(ev: DevJobEvent): TaskEventRecord { + const message = ev.payload['message']; + return { + seq: ev.id, + ts: ev.ts, + type: ev.type, + message: typeof message === 'string' ? message : JSON.stringify(ev.payload), + }; +} + +// --------------------------------------------------------------------------- +// Injected surfaces — narrow reads of the real stores (mirrors the route seams +// in `chatDevJobService.ts`, so a test can drive this with fakes). +// --------------------------------------------------------------------------- + +/** The subset of `DevJobStore` this adapter uses. */ +export interface DevJobTaskJobStore { + createJob(input: NewDevJob & { runnerTokenHash: string }): Promise; + getJob(id: string): Promise; + listJobs(filter?: ListJobsFilter): Promise; + claimNextQueued(claimedBy: string): Promise; + touchHeartbeat(jobId: string): Promise; + appendEvents( + jobId: string, + provision: number, + events: readonly { seq: number; type: string; payload: Record }[], + ): Promise; + findStalled(cutoff: Date, scope?: DevJobSweepScope): Promise; + /** Optional so a unit-test fake need not implement it; absent ⇒ empty tail. */ + listEvents?( + jobId: string, + afterId?: number, + limit?: number, + ): Promise; +} + +export interface DevJobTaskStoreDeps { + readonly jobStore: DevJobTaskJobStore; + /** + * How a task is created. Bound by the caller to the SAME launch path chat + * uses (`ChatDevJobService.startJob`) so this adapter never duplicates launch + * authorization — a repo the operator may not launch on stays unreachable. + */ + readonly createJob: (input: unknown) => Promise; + /** + * The terminal transition. Bound to `finalizeDevJob` so the brand-gated single + * choke point is preserved: this adapter cannot and does not call + * `DevJobStore.finishTerminal` itself. + */ + readonly finalize: ( + jobId: string, + status: DevJobStatus, + patch: { error?: string }, + ) => Promise; + /** Terminal-row purge, bound to `DevRetentionRunner.purgeTerminalJobs`. */ + readonly purgeTerminalJobs?: ( + olderThanDays: number, + now: Date, + ) => Promise; + /** + * W3-A — narrows `reapOrphans`' stalled sweep. OMITTED in production, where the + * reaper is deliberately database-global (see {@link DevJobSweepScope}). + * + * Set at CONSTRUCTION rather than per call because `reapOrphans` implements the + * generic {@link TaskStore} contract, whose `TaskReapOptions` must stay free of + * dev-platform concepts like a repo id. + */ + readonly sweepScope?: DevJobSweepScope; +} + +const MS_PER_DAY = 86_400_000; + +/** Host/control-plane event provision (`devJobStore.HOST_EVENT_PROVISION`). */ +const HOST_PROVISION = 0; + +/** + * Build the `dev_job` view of the {@link TaskStore} seam. + * + * Every method delegates; nothing is reimplemented. That is the point — the + * conformance test drives real dev_jobs rows through this and asserts the + * behaviour is byte-for-byte the behaviour `devJobStore.pg.test.ts` already + * pins for the same operations. + */ +export function createDevJobTaskStore(deps: DevJobTaskStoreDeps): TaskStore { + const { jobStore } = deps; + + /** + * Resolve a job for a fenced write. Accepts the write when the lease matches, + * or when the job holds NO lease (the administrative / reaper case dev_job + * legitimately relies on — see the module header). Rejects a mismatched lease. + */ + async function fenced(id: string, lease: string): Promise { + const job = await jobStore.getJob(id); + if (!job) throw new TaskLeaseLostError(id); + if (job.claimedBy !== null && job.claimedBy !== lease) { + throw new TaskLeaseLostError(id); + } + return job; + } + + return { + async create(input: NewTaskInput): Promise { + const job = await deps.createJob(input.input); + return toTaskDescriptor(job); + }, + + async get(id: string): Promise { + const job = await jobStore.getJob(id); + return job ? toTaskDescriptor(job) : null; + }, + + async list(filter: TaskListFilter = {}): Promise { + // The seam's four-value status is a MANY-to-one projection, so it cannot be + // pushed into SQL as a single `status =`. Fetch and project, then filter — + // with the limit applied AFTER projection so a `working` filter cannot + // silently return fewer rows than exist because terminal rows ate the LIMIT. + const jobs = await jobStore.listJobs({ limit: 500 }); + let out = jobs.map(toTaskDescriptor); + if (filter.kind !== undefined) out = out.filter((d) => d.kind === filter.kind); + if (filter.status !== undefined) { + out = out.filter((d) => d.status === filter.status); + } + const limit = Math.min(Math.max(1, Math.trunc(filter.limit ?? 50)), 500); + return out.slice(0, limit); + }, + + async eventTail(id: string, limit: number): Promise { + const n = Math.min(Math.max(1, Math.trunc(limit)), 2000); + const events = (await jobStore.listEvents?.(id, undefined, 2000)) ?? []; + return events.slice(-n).map(toTaskEvent); + }, + + async claimNextPending( + lease: string, + kind?: string, + taskId?: string, + ): Promise<{ descriptor: TaskDescriptor; input: unknown } | null> { + if (!TASK_LEASE_UUID_RE.test(lease)) { + throw new TypeError(`claimNextPending: lease must be a UUID (got '${lease}')`); + } + const job = await jobStore.claimNextQueued(lease); + if (!job) return null; + // The `taskId` claim hint is ADVISORY and this store cannot honour it: + // `claimNextQueued` is a bare `FOR UPDATE SKIP LOCKED` pool pop with no id + // predicate, and dev_job exposes no release primitive, so narrowing here + // would mean claiming a job and then abandoning it under a live lease — + // exactly the strand the hint exists to prevent. The seam contract + // therefore makes the RETURNED descriptor authoritative and requires the + // caller to follow its claim through; the hint is recorded as observed and + // deliberately not applied. + void taskId; + // dev_job's claim is not kind-filtered (its worker claims any queued job). + // Surfacing that honestly: a kind filter the underlying store cannot honour + // is reported as "nothing to claim" rather than silently claiming the wrong + // kind and handing it to the wrong executor. + if (kind !== undefined && job.kind !== kind) return null; + return { descriptor: toTaskDescriptor(job), input: { jobId: job.id } }; + }, + + async heartbeat(id: string, lease: string): Promise { + await fenced(id, lease); + const ok = await jobStore.touchHeartbeat(id); + // 0 rows ⇒ the job went terminal between the read and the write. + if (!ok) throw new TaskLeaseLostError(id); + }, + + async setPhase(id: string, lease: string, phase: string): Promise { + // Phase transitions in dev_job are `advancePhase(from, to)` — fenced on the + // phase being LEFT, which a generic `setPhase(to)` cannot express without + // re-reading and racing. dev_job's pipeline owns its own phase machine + // (`phaseEngine.ts`), so the seam does not get to drive it: this is a + // deliberate no-op after the fence check, not a silent failure. + await fenced(id, lease); + void phase; + }, + + async appendEvents( + id: string, + lease: string, + events: readonly { type: string; message: string }[], + ): Promise { + if (events.length === 0) return; + await fenced(id, lease); + // Host-emitted events use provision 0 and a per-batch seq, matching + // `finalizeDevJob`'s own status writes. + await jobStore.appendEvents( + id, + HOST_PROVISION, + events.map((e, i) => ({ + seq: i, + // dev_job validates its event type union; anything the seam does not + // map is recorded as a `log` line rather than throwing away the batch. + type: e.type === 'status' || e.type === 'phase' ? e.type : 'log', + payload: { message: e.message }, + })), + ); + }, + + async finish( + id: string, + lease: string, + patch: TerminalTaskPatch, + ): Promise { + await fenced(id, lease); + const devStatus: DevJobStatus = patch.status === 'completed' ? 'done' : 'failed'; + const job = await deps.finalize(id, devStatus, { + ...(patch.error !== undefined ? { error: patch.error } : {}), + }); + if (!job) throw new TaskLeaseLostError(id); + return toTaskDescriptor(job); + }, + + async requireInput(id: string, lease: string): Promise { + // dev_job reaches `input_required` only via its own gate machine (the W2 + // plan gate parks the job at `await_human` with status `waiting`), and the + // gate must be attributable to a HUMAN — never to a model turn. So the seam + // is NOT given a way to open one; it can only observe the projection. + const job = await fenced(id, lease); + return toTaskDescriptor(job); + }, + + async reapOrphans(opts: TaskReapOptions): Promise { + const now = opts.now ?? new Date(); + const cutoff = new Date(now.getTime() - opts.staleAfterMs); + const stalled = await jobStore.findStalled( + cutoff, + ...(deps.sweepScope ? ([deps.sweepScope] as const) : ([] as const)), + ); + let staleFailed = 0; + for (const job of stalled) { + const finished = await deps.finalize(job.id, 'stalled', { + error: + 'task abandoned: no worker heartbeat within the orphan window ' + + '(worker crashed, restarted, or was never started)', + }); + if (finished) staleFailed += 1; + } + const purged = deps.purgeTerminalJobs + ? await deps.purgeTerminalJobs( + Math.max(1, Math.ceil(opts.purgeTerminalAfterMs / MS_PER_DAY)), + now, + ) + : 0; + return { staleFailed, purged }; + }, + }; +} diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 88547f3f..ff905e49 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -162,6 +162,16 @@ import { type MdnsAdvertisement, } from './pairing/mdns.js'; import { publicPaths } from './auth/publicPaths.js'; +import { mountPublicMcp } from './mcp/wirePublicMcp.js'; +// W5-1 — the WRITE half of `public_mcp_key_bindings`. Imported for the +// OPERATOR router only. `mountPublicMcp` above must never be handed this: the +// internet-facing endpoint gets `createPublicMcpKeyBindingStore` (read-only) +// and nothing else. +import { createPublicMcpKeyBindingAdminStore } from './mcp/publicMcpKeyBindingsAdmin.js'; +import { + PRIVACY_REDACT_SERVICE_NAME, + type PrivacyGuardService, +} from '@omadia/plugin-api'; import { createRequireAuth } from './auth/requireAuth.js'; import { createOperatorAuthAccessor } from './auth/operatorAuthAccessor.js'; import { assembleDevPlatform, mountDevPlatform } from './devplatform/wireDevPlatform.js'; @@ -276,8 +286,27 @@ import { DeterministicActionRegistry } from './platform/deterministicActionRegis import { ServiceRegistry } from './platform/serviceRegistry.js'; import { TurnHookRegistry } from './platform/turnHookRegistry.js'; import { NativeToolRegistry } from '@omadia/orchestrator'; +// W3-A / W4 — boot-time enforcement of the tool-timeout ordering invariant. +import { assertTimeoutHierarchy } from '@omadia/orchestrator'; +// W2-2 (issue #543) — generic long-running task seam. +import { InMemoryTaskStore, startTaskReaper } from '@omadia/orchestrator'; import { McpManager, type McpCallLogEntry, type McpServerConfig } from '@omadia/orchestrator'; +// W2-1 (#544) — MRTR mid-call user input: the process-shared park store and the +// replayer registration. See `mcp/pendingMcpInput.ts` for why these are shared. +import { + REPLAY_ARG_KEY, + setSharedMcpInputReplayer, + sharedPendingMcpInputStore, +} from '@omadia/orchestrator'; +import { + SERVICE_USER_KEY, + auditIdentity, + delegationBlockedMessage, + resolveMcpUserKey, +} from './services/mcpDelegation.js'; import { McpOAuthService } from './services/mcpOAuthService.js'; +import { CIMD_METADATA_PATH, cimdMetadataUrl } from './services/mcpCimd.js'; +import { createMcpClientMetadataRouter } from './routes/mcpClientMetadata.js'; import { McpConfigService } from './services/mcpConfigService.js'; import { McpRegistrySecretService, @@ -369,6 +398,14 @@ async function main(): Promise { // fire-and-forget I/O) throws. installProcessGuards(); + // W3-A / W4 — refuse to boot on an incoherent tool-timeout hierarchy. The + // ordering (dispatch deadline > MCP worst case > per-request idle budget) used + // to be asserted only inside a test helper that nothing shipped ever called, + // so `OMADIA_TOOL_DISPATCH_TIMEOUT_MS=90000` inverted it with green CI and the + // symptom surfaced much later as MCP calls dying on a generic + // dispatch-deadline error. Config errors belong at startup. + assertTimeoutHierarchy(); + // Plugin-api registries. Created empty at boot; populated as plugins // register into them during the activation sequence further down. Today // only the ServiceRegistry participates in the happy path (plumbed into @@ -393,6 +430,26 @@ async function main(): Promise { // plugin would miss those registrations. const nativeToolRegistry = new NativeToolRegistry(); serviceRegistry.provide('nativeToolRegistry', nativeToolRegistry); + // W2-2 (issue #543) — the store + orphan reaper backing deferred sub-agent + // dispatch. Process-local by design: this unit ships no migration (0031/0032 + // are taken by parallel units), so a restart drops in-flight deferred tasks + // and a poll answers "not found" rather than something wrong. The reaper is + // what stops an unpolled task leaking a `working` row forever. + const longRunningSubAgentTools = config.LONG_RUNNING_SUBAGENT_TOOLS.split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const subAgentTaskStore = new InMemoryTaskStore(); + if (longRunningSubAgentTools.length > 0) { + startTaskReaper(subAgentTaskStore, { + staleAfterMs: config.LONG_RUNNING_TASK_STALE_MS, + purgeTerminalAfterMs: config.LONG_RUNNING_TASK_RETAIN_MS, + onError: (err: unknown) => + console.warn('[middleware] long-running task reaper sweep failed:', err), + }); + console.log( + `[middleware] deferred sub-agent dispatch enabled for: ${longRunningSubAgentTools.join(', ')}`, + ); + } // LLM provider catalog: kernel-owned registry of plugin-contributed providers // (e.g. @omadia/plugin-llm-minimax). Published pre-activate and populated from // installed plugins' `llm_provider` manifest blocks below, so the orchestrator @@ -1524,19 +1581,36 @@ async function main(): Promise { // Generic MCP OAuth service (epic #459 W9) — outer scope so both the // McpManager (auth provider) and the operator router (begin/callback routes) - // reference the same instance. userKey='operator' for the operator chat. - const mcpOAuthUserKey = 'operator'; + // reference the same instance. + // + // W0-1: this is now ONLY the shared key for servers whose `delegation` is + // `service`. It is no longer a fallback for unresolved identities — see + // services/mcpDelegation.ts. + const mcpOAuthUserKey = SERVICE_USER_KEY; // Redirect URI the OAuth callback lands on: explicit override, else derived // from the public base. The service activates when either is configured. const mcpOAuthRedirectUri = config.MCP_OAUTH_REDIRECT_URI ?? (flowPublicBaseUrl ? `${flowPublicBaseUrl}/api/v1/operator/mcp-oauth/callback` : undefined); + // W2-4 (issue #546) — the CIMD metadata-document URL, i.e. the `client_id` a + // CIMD-capable authorization server dereferences. + // + // Derived from `FLOW_PUBLIC_BASE_URL` ALONE — deliberately NOT the + // `?? PUBLIC_BASE_URL` fallback the redirect URI uses. CIMD needs the IdP to + // reach IN to this deployment, and `PUBLIC_BASE_URL` defaults to + // `http://localhost:3979`, which is exactly the shape that is not inbound + // reachable. Requiring the operator to declare the public origin explicitly + // means an unconfigured install lands in the clean degraded state (CIMD off, + // manual client path fully working) instead of publishing a `client_id` no + // provider can fetch. + const mcpCimdMetadataUrl = cimdMetadataUrl(config.FLOW_PUBLIC_BASE_URL); const mcpOAuthService = graphPool && mcpOAuthRedirectUri ? new McpOAuthService({ graph: new AgentGraphStore(graphPool), vault: secretVault, redirectUri: mcpOAuthRedirectUri, + cimdMetadataUrl: mcpCimdMetadataUrl, log: (m) => console.log(`[middleware] ${m}`), }) : undefined; @@ -1810,6 +1884,10 @@ async function main(): Promise { // Generic MCP OAuth (epic #459 W9) wired as the manager's auth provider; // the service instance is created at outer scope (shared with the router). const mcpManager = new McpManager({ + // W2-1 (#544) — where a `resultType: "input_required"` call is parked. + // The process-shared instance, so the Orchestrator's turn drain reads + // exactly what this manager wrote. + pendingInput: sharedPendingMcpInputStore(), ...(mcpAuditStore ? { onToolCall: (entry: McpCallLogEntry) => { @@ -1829,18 +1907,49 @@ async function main(): Promise { const server = (await mcpAuditStore.listMcpServers()).find((s) => s.id === cfg.id); if (!server) return null; // Per-user token (codex W9 fold): the turn's authenticated - // user when the entry point set it, else the operator scope. - const userKey = turnContext.current()?.mcpUserKey ?? mcpOAuthUserKey; + // user when the entry point set it. + // + // W0-1 (D2) — THE confused-deputy fix. This used to end in + // `?? mcpOAuthUserKey`, i.e. `'operator'`. A Teams/Telegram + // turn whose user has no mapped identity therefore reached + // the customer's MCP server holding the OPERATOR's token. + // Now a `per_user` server with no identity gets no token and + // the call fails closed through onAuthFailure below; + // `service` delegation is the explicit shared-identity + // opt-in. + const userKey = resolveMcpUserKey( + server, + turnContext.current()?.mcpUserKey, + mcpOAuthUserKey, + ); + if (userKey === null) return null; return mcpOAuthService.getValidAccessToken(server, userKey); }, + resolveIdentity: async (cfg: McpServerConfig) => { + const server = (await mcpAuditStore.listMcpServers()).find((s) => s.id === cfg.id); + if (!server) return null; + // W0-1: every audit row names the identity it acted as — + // `unresolved` when there was none. + return auditIdentity(server, turnContext.current()?.mcpUserKey, mcpOAuthUserKey); + }, onAuthFailure: async (cfg: McpServerConfig) => { const server = (await mcpAuditStore.listMcpServers()).find((s) => s.id === cfg.id); if (!server) return null; + // W0-1 (D2): fail closed FIRST. A `per_user` server with no + // caller identity must never be "fixed" by starting an OAuth + // flow — that flow would bind a token to whoever happens to + // click through, which is the same confused deputy one step + // removed. Explain instead, and send nothing upstream. + const userKey = resolveMcpUserKey( + server, + turnContext.current()?.mcpUserKey, + mcpOAuthUserKey, + ); + if (userKey === null) return delegationBlockedMessage(server.name); // Only OAuth-protected servers get an auth prompt (cached // discovery keeps this cheap per call). const desc = await mcpOAuthService.describeAuth(server); if (!desc.protected) return null; - const userKey = turnContext.current()?.mcpUserKey ?? mcpOAuthUserKey; // Machine block the chat parses into an in-line "Connect" card // + modal (web-ui McpAuthRequiredCard). Mirrors the // block contract: human text stays readable for the model and @@ -1869,6 +1978,39 @@ async function main(): Promise { } : {}), }); + // W2-1 (#544) — the replayer. Registered here because this is the only + // place that holds BOTH the manager and the server registry: a replay is + // a fresh `tools/call`, so it needs the server's live config (endpoint, + // headers, Vault-resolved env) re-resolved rather than a snapshot taken + // when the call was parked. + // + // A server the operator deleted (or renamed away) between the two turns + // returns `undefined`, which the orchestrator surfaces to the user as + // "no longer reachable" instead of silently dropping their input. + if (mcpAuditStore) { + setSharedMcpInputReplayer({ + replay: async (record, inputResponses) => { + const server = (await mcpAuditStore.listMcpServers()).find( + (s) => s.id === record.serverId, + ); + if (!server) return undefined; + const cfg: McpServerConfig = { + id: server.id, + name: server.name, + transport: server.transport, + endpoint: server.endpoint, + ...(server.privacyBypass ? { privacyBypass: true } : {}), + }; + // `originalArgs` first: a server that (incorrectly) declared an + // `inputResponses` input field must not be able to shadow the + // collected answers with a stale value from the original call. + return mcpManager.callTool(cfg, record.toolName, { + ...record.originalArgs, + [REPLAY_ARG_KEY]: inputResponses, + }); + }, + }); + } // Host MCP service for plugin ctx.mcp (epic #459 W5, issue #458): // resolved lazily by createPluginContext, exactly like the 'llm' // provider. Grants are read live per call (deny-by-default). @@ -1940,6 +2082,10 @@ async function main(): Promise { cliModelAlias: (model: string): string => model.replace(/-cli$/, '') || 'sonnet', blockedMcpGrant: isMcpGrantBlocked, + // W2-2 (issue #543) — deferred sub-agent dispatch. Empty allowlist + // (the default) leaves every sub-agent on today's inline path. + longRunningSubAgentTools: longRunningSubAgentTools, + taskStore: subAgentTaskStore, log: (m: string) => console.log(`[middleware] ${m}`), }, ); @@ -2228,6 +2374,24 @@ async function main(): Promise { }); console.log(`[middleware] pairing discovery at GET ${WELL_KNOWN_PATH}`); + // W2-4 (issue #546) — MCP Client ID Metadata Document. Public by necessity: + // an authorization server fetches it uncredentialed to resolve the CIMD + // `client_id`. Mounted here, outside the `/api` requireAuth mount, AND listed + // in auth/publicPaths.ts via the shared `CIMD_METADATA_PATH` constant so the + // two can never drift. `redirectUri` is the SAME variable McpOAuthService + // holds — if these diverge, every code exchange fails at the provider. + app.use( + createMcpClientMetadataRouter({ + metadataUrl: mcpCimdMetadataUrl, + redirectUri: mcpOAuthRedirectUri ?? null, + }), + ); + console.log( + mcpCimdMetadataUrl && mcpOAuthRedirectUri + ? `[middleware] MCP client-ID metadata document at GET ${CIMD_METADATA_PATH} (client_id ${mcpCimdMetadataUrl})` + : `[middleware] MCP client-ID metadata document at GET ${CIMD_METADATA_PATH} answers 501 — FLOW_PUBLIC_BASE_URL unset, so CIMD is off and issuers use the manual client path`, + ); + // Harness shared assets — currently the admin-UI baseline stylesheet // that plugin-bundled admin UIs `` into their HTML. No auth: the // CSS is static and operator-agnostic. See PLAN-admin-ui-theming.md. @@ -2278,6 +2442,29 @@ async function main(): Promise { }), ); + // W2-3 (issue #542) — the public, stateless MCP endpoint. + // + // Mounted AFTER the `/api` requireAuth line above ON PURPOSE. That mount runs + // for every `/api/*` request whichever router answers it, so being listed in + // `auth/publicPaths.ts` is what makes this route reachable at all — and + // losing that entry makes it go DARK (401) rather than open. `requireApiKey` + // inside the router is the actual authentication; the per-key tool allowlist + // and the per-tool write scopes are the actual authorization. + mountPublicMcp(app, requireAuth, { + enabled: config.PUBLIC_MCP_ENABLED, + allowWithoutPrivacyMasking: config.PUBLIC_MCP_ALLOW_WITHOUT_PRIVACY_MASKING, + vault: secretVault, + graphPool, + getRegistry, + nativeToolRegistry, + // Resolved LIVE from the service registry, the same late-bound pattern the + // orchestrator plugin uses: installing the privacy-guard plugin takes effect + // without a restart, and — the direction that matters here — uninstalling it + // closes the endpoint's tool calls immediately rather than on next boot. + getPrivacyService: () => + serviceRegistry.get(PRIVACY_REDACT_SERVICE_NAME), + }); + // Chat-sessions CRUD behind `requireAuth` — sessions may contain // PII / tool outputs / code snippets and must not be readable anonymously. // The `/api` mount above already gates this, but the explicit middleware @@ -2559,6 +2746,15 @@ async function main(): Promise { ...(mcpOAuthService ? { mcpOAuth: mcpOAuthService, mcpOAuthUserKey } : {}), ...(mcpConfigService ? { mcpConfig: mcpConfigService } : {}), ...(mcpRegistrySecrets ? { mcpRegistrySecrets } : {}), + // W5-1 — the operator surface for `public_mcp_key_bindings`, without + // which the public MCP endpoint cannot be configured except by hand in + // psql. A fresh store per call so a graphPool that arrives later is + // picked up without a restart, matching `getGraphStore` above. + getPublicMcpBindingStore: () => + graphPool ? createPublicMcpKeyBindingAdminStore(graphPool) : undefined, + // Explicit gate on those routes, independent of the `requireAuth` that + // sits in front of this mount. + operatorAuth, }), ); console.log( diff --git a/middleware/src/mcp/README.md b/middleware/src/mcp/README.md new file mode 100644 index 00000000..c86680d3 --- /dev/null +++ b/middleware/src/mcp/README.md @@ -0,0 +1,192 @@ +# Public MCP Endpoint (`POST /api/v1/mcp`) + +Exposes omadia's own tools over a **stateless Streamable-HTTP MCP server** so an +external MCP client (Claude Desktop, an agent framework, your own service) can +call them with an API key instead of driving the operator UI. This document is +for **external API consumers** — if you are looking for how the endpoint is +built, read the source in this directory, starting at `publicMcpServer.ts`. + +Mirrors the shape of `packages/harness-channel-api/README.md`, which documents +the sibling public chat ingress. + +> **Dark by default.** The endpoint is not mounted at all unless the operator +> sets `PUBLIC_MCP_ENABLED=true`. If you get a 404, it is off. + +## Surface + +| Surface | What it does | +|---|---| +| `POST /api/v1/mcp` | The one public route. MCP JSON-RPC over HTTP: `tools/list`, `tools/call`. Self-authenticating (bearer API key) — no session cookie. | +| `GET`/anything else on that path | `405 Method Not Allowed`. There is no standalone SSE stream. | + +Key lifecycle (create/list/revoke) is **not** part of this API — see "Getting an +API key" below. + +## Stateless by design + +There is **no session**. You do not need to call `initialize`, you will never be +issued an `Mcp-Session-Id`, and you must not send one. Every POST is independently +answered, which is what lets an operator run several omadia instances behind a +load balancer and have any of them serve any of your requests. + +Practically: send the JSON-RPC request you want, on its own, every time. + +## Authentication + +A bearer API key, exactly like the public chat ingress: + +``` +Authorization: Bearer +``` + +- `401` — missing, unknown, or revoked key. Not retryable. +- `403` — the key authenticated but lacks a required scope. Not retryable. +- `429` — you exceeded the key's per-minute request budget. Retry after a pause. + +The key is a **server credential**. It must never be shipped to a browser or an +end-user device: there is no session, no cookie and no consent step, so the key +is the whole identity. + +## Scopes + +Four capabilities, and you need the right combination. All default-deny. + +| Scope | Grants | +|---|---| +| `mcp:list` | Call `tools/list`. | +| `mcp:invoke` | Call `tools/call` for a **read** tool. | +| `mcp:write:` | Call `tools/call` for the **one** write tool named. | +| `*` | Everything **except** any `mcp:write:`. | + +Three consequences worth internalising before you file a bug: + +1. **`mcp:invoke` is not enough for a write.** Every write tool needs its own + `mcp:write:` scope, named for exactly that tool. There is no class-wide + write scope, and `mcp:write` on its own is rejected as invalid at key-creation + time. +2. **The wildcard `*` does not grant writes.** This is deliberate: `*` is a + convenience for an operator's own tooling, and silently including "mutate + production data over the internet" in that convenience is not a trade anyone + makes consciously. +3. **`tools/list` shows you exactly what you can call — no more.** If a tool is + missing from the list, either it is not on your key's allowlist or you lack + its scope. The list is not a catalogue of what exists; it is your own + capability set. This is intentional: a tool name you cannot call would still + tell you which integrations the install runs. + +## Your key's tool allowlist + +Beyond scopes, the operator binds your key to: + +- **exactly one agent**, and +- **an explicit list of tool names** on that agent. + +A key with no binding authenticates fine and reaches **zero** tools. Nothing is +included until an operator names it — which is how integration-backed and +write-capable tools (Odoo, Microsoft 365, Confluence) stay out of reach by +default. + +Some tools can **never** be exposed here regardless of what an operator +configures — the ones omadia's Privacy Shield deliberately exempts from masking +because the agent needs to read its own state in clear (`memory`, +`read_attachment`, and a small fixed set of others). Those are filtered out +unconditionally. + +If a tool you expected is missing, ask the operator to add it to your key's +binding. You cannot change it yourself. + +## Calling a tool + +```bash +# List what this key can call +curl -sS -X POST https:///api/v1/mcp \ + -H "Authorization: Bearer $OMADIA_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' + +# Call one +curl -sS -X POST https:///api/v1/mcp \ + -H "Authorization: Bearer $OMADIA_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call", + "params":{"name":"query_crm","arguments":{"q":"acme"}}}' +``` + +The `Accept` header must include both `application/json` and +`text/event-stream` — that is an MCP transport requirement, not an omadia one. + +Any MCP client library works too; point it at the URL, set the bearer token, and +do not configure a session. + +## What tool results look like + +**Tool results are masked.** omadia's Privacy Shield interns raw tool output +server-side and returns an identity-free digest, so personal data in an +underlying record does not reach you. Expect placeholder-style values where +identities would be, and write your integration against the digest — not against +the shape of the upstream system's rows. + +If masking is unavailable or fails, the call is **refused** rather than answered +with unmasked data. You will see an error, not a result. That is intentional and +not retryable in a tight loop — report it to the operator. + +## Limits + +| Limit | Value | +|---|---| +| Request body | 8 MB | +| Per-tool execution time | 30 s, then the call errors | +| Concurrent tool calls (whole endpoint) | 4; excess calls get "at capacity" | +| Requests/minute | per-key, set by the operator | +| **Write** calls/minute | a **separate, tighter** per-key budget | + +Reads and writes draw on **different** rate-limit budgets. Exhausting your write +budget does not stop you reading, and heavy reading does not consume write +headroom. + +## Idempotency — read this before relying on it + +You may attach an idempotency key to a `tools/call`: + +```json +{"jsonrpc":"2.0","id":2,"method":"tools/call", + "params":{"name":"create_lead","arguments":{"…":"…"}, + "_meta":{"idempotencyKey":"your-uuid"}}} +``` + +It rides in `params._meta` because **MCP standardizes no idempotency field**. +That makes it advisory, and the guarantee is narrower than the name suggests: + +- It applies to **write-capable tools only**. Reads ignore it (deduping a read + would serve you stale data). +- It is **process-local**, with a ~15 minute window. If the operator runs more + than one omadia instance behind a load balancer, **two requests carrying the + same key can both execute** — they may land on different instances that share + no dedupe state. +- It is therefore a **retry-safety mitigation, not distributed exactly-once**. + +What you can rely on: retrying the *same* call with the *same* key against the +*same* instance inside the window will not execute the tool twice. What you +cannot rely on: that a write happened at most once globally. If at-most-once +matters for your use case, make the underlying operation idempotent on your side +(natural keys, upserts, reconciliation) and treat this field as a bonus. + +## Audit + +Every call — including every refusal — is recorded in the operator's MCP call log +with the acting identity of your key (`apikey:`). Tool arguments and results +are **not** recorded. The operator can see that you called `create_lead` and +whether it succeeded; they cannot read the payload out of the audit trail. + +## Getting an API key + +Keys are issued and managed by the omadia operator, not by external callers, and +are the **same keys** the public chat ingress uses — scopes decide which surface +a given key can reach. Ask the operator to create one with the scopes and tool +allowlist you need; the plaintext token is shown to them exactly once, at +creation time, and is never recoverable afterwards (only its hash is stored). + +See `packages/harness-channel-api/README.md` § "Getting an API key" for the +mechanism. diff --git a/middleware/src/mcp/publicMcpKeyBindings.ts b/middleware/src/mcp/publicMcpKeyBindings.ts new file mode 100644 index 00000000..e5741fe9 --- /dev/null +++ b/middleware/src/mcp/publicMcpKeyBindings.ts @@ -0,0 +1,213 @@ +/** + * W2-3 (issue #542) — the per-key authorization record for the public MCP + * endpoint: which agent a key is bound to, and exactly which of that agent's + * tools it may read and write. + * + * This is the seam the issue assumed already existed. omadia's native tool + * registry is a process-wide singleton with unique tool names; per-agent + * scoping existed only for DomainTools (`scopeDomainToolsToPlugins`), and the + * loopback MCP server's own security note says as much: "the subscription CLI + * sees the FULL native tool registry via the loopback MCP server, with no + * allowlist beyond MCP server scoping; a per-agent tool allowlist is a + * follow-up." An internet-facing endpoint cannot ship on that footing, so the + * allowlist is built here, per KEY rather than per server. + * + * Everything in this module fails CLOSED. Absent row, disabled row, malformed + * row, unreadable column: all resolve to "this key reaches no tools". There is + * deliberately no code path that turns a read problem into a grant — the + * asymmetry mirrors `normalizeScopes` in `@omadia/api-key-auth`, which denies + * everything on a malformed persisted `scopes` field for the same reason. + */ + +import type { Pool } from 'pg'; + +/** The resolved authorization for one API key. */ +export interface PublicMcpKeyBinding { + /** `ApiKeyRecord.id` — the key this binding belongs to. */ + readonly keyId: string; + /** The ONE agent (orchestrator slug) whose tools this key reaches. */ + readonly agentId: string; + /** Exact names of read-only tools this key may call. No patterns. */ + readonly readTools: readonly string[]; + /** Exact names of write-capable tools this key may call. Calling one + * additionally requires the `mcp:write:` scope and spends the write + * rate-limit budget. */ + readonly writeTools: readonly string[]; + /** Tighter per-minute budget for writes, independent of the key's general + * `rateLimitPerMinute`. */ + readonly writeRateLimitPerMinute: number; +} + +/** + * Reads bindings. Read-only by design: bindings are operator-managed + * configuration, and this endpoint — the internet-facing one — has no business + * holding a writer for its own authorization data. + */ +export interface PublicMcpKeyBindingStore { + /** The binding for `keyId`, or `undefined` when the key reaches nothing. + * `undefined` covers absent, disabled, and malformed alike: a caller that + * cannot distinguish them cannot accidentally treat one as permissive. */ + get(keyId: string): Promise; +} + +/** A binding that grants nothing, for the "row exists but says no" case. */ +export function denyAllBinding(keyId: string, agentId: string): PublicMcpKeyBinding { + return { keyId, agentId, readTools: [], writeTools: [], writeRateLimitPerMinute: 0 }; +} + +/** + * Shapes a raw row into a binding, or `undefined` when the row cannot be + * trusted. + * + * Exported so both store implementations and the tests share ONE normalization + * rule. The pg driver returns `TEXT[]` as a JS array, but a hand-edited row, a + * future column-type change, or a NULL where the schema promises NOT NULL would + * all arrive here as something else — and each of those must deny, not partly + * grant. + */ +export function normalizeBindingRow(raw: { + key_id?: unknown; + agent_id?: unknown; + read_tools?: unknown; + write_tools?: unknown; + write_rate_limit_per_minute?: unknown; + enabled?: unknown; +}): PublicMcpKeyBinding | undefined { + const keyId = nonEmptyString(raw.key_id); + const agentId = nonEmptyString(raw.agent_id); + if (!keyId || !agentId) { + warnMalformed('key_id or agent_id missing/empty', keyId ?? ''); + return undefined; + } + + // `enabled` is NOT NULL DEFAULT true in the schema, so anything other than a + // boolean is corruption. Treat it as disabled rather than guessing `true`: + // guessing wrong in that direction reopens an endpoint an operator parked. + if (typeof raw.enabled !== 'boolean') { + warnMalformed('enabled is not a boolean', keyId); + return undefined; + } + if (!raw.enabled) return undefined; + + const readTools = normalizeToolList(raw.read_tools, 'read_tools', keyId); + const writeTools = normalizeToolList(raw.write_tools, 'write_tools', keyId); + if (!readTools || !writeTools) return undefined; + + // A tool named in BOTH lists is ambiguous about whether it needs + // `mcp:write:`. Resolve toward the STRICTER reading — it is a write — + // rather than rejecting the whole row, because an operator adding a write + // capability to a tool they had listed as a read is a plausible edit and + // silently downgrading it to a read would be the dangerous resolution. + const writeSet = new Set(writeTools); + const readOnly = readTools.filter((t) => !writeSet.has(t)); + + const writeLimit = normalizeRateLimit(raw.write_rate_limit_per_minute); + if (writeLimit === undefined) { + warnMalformed('write_rate_limit_per_minute is not a usable integer', keyId); + return undefined; + } + + return { + keyId, + agentId, + readTools: readOnly, + writeTools: Array.from(writeSet), + writeRateLimitPerMinute: writeLimit, + }; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** `undefined` signals "deny the whole row"; `[]` is a legitimate empty list. */ +function normalizeToolList( + raw: unknown, + column: string, + keyId: string, +): readonly string[] | undefined { + if (raw === null || raw === undefined) { + // Schema says NOT NULL DEFAULT '{}', so NULL here is a foreign writer. + warnMalformed(`${column} is null`, keyId); + return undefined; + } + if (!Array.isArray(raw)) { + warnMalformed(`${column} is not an array`, keyId); + return undefined; + } + const invalid = raw.filter((entry) => nonEmptyString(entry) === undefined); + if (invalid.length > 0) { + // Partially-valid arrays deny rather than narrowing to the valid subset — + // same rule and same reasoning as `normalizeScopes`: a record we cannot + // read faithfully is one we must not guess at. + warnMalformed(`${column} holds ${String(invalid.length)} non-string entr(y|ies)`, keyId); + return undefined; + } + return Array.from(new Set(raw as readonly string[])); +} + +function normalizeRateLimit(raw: unknown): number | undefined { + if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 0) return raw; + // pg returns some integer types as strings depending on the type OID; accept + // a clean integer string rather than denying a perfectly good row. + if (typeof raw === 'string' && /^\d+$/.test(raw)) return Number(raw); + return undefined; +} + +/** A malformed binding silently stops a key from reaching anything; without + * this line an operator cannot tell that from a deliberate revoke. Only the + * key id and the reason are logged — never the row, which names the tools an + * integration is trusted with. */ +function warnMalformed(reason: string, keyId: string): void { + console.warn( + `[public-mcp] unusable key binding (${reason}) for key ${keyId} — key reaches no tools until the row is repaired`, + ); +} + +/** + * Postgres-backed store. + * + * No cache. A binding is read once per MCP request against a primary-key + * lookup, and an operator revoking a tool from an integration expects that to + * take effect on the next call rather than after a TTL. If this ever becomes + * hot, the fix is a short negative cache — never a positive one, because a + * cached grant is a grant that outlives its revocation. + */ +export function createPublicMcpKeyBindingStore(pool: Pool): PublicMcpKeyBindingStore { + return { + async get(keyId) { + if (nonEmptyString(keyId) === undefined) return undefined; + const { rows } = await pool.query( + `SELECT key_id, agent_id, read_tools, write_tools, write_rate_limit_per_minute, enabled + FROM public_mcp_key_bindings + WHERE key_id = $1`, + [keyId], + ); + const row = rows[0] as Parameters[0] | undefined; + return row ? normalizeBindingRow(row) : undefined; + }, + }; +} + +/** + * In-memory store, for tests and for a DATABASE_URL-less install. + * + * Takes RAW row shapes rather than ready-made `PublicMcpKeyBinding` values on + * purpose: a test that hands over a well-formed object bypasses + * `normalizeBindingRow` entirely and therefore proves nothing about the + * fail-closed rules the pg path relies on. Same normalization, same denials. + */ +export function createInMemoryPublicMcpKeyBindingStore( + rows: readonly Parameters[0][], +): PublicMcpKeyBindingStore { + const byKey = new Map[0]>(); + for (const row of rows) { + if (typeof row.key_id === 'string') byKey.set(row.key_id, row); + } + return { + async get(keyId) { + const row = byKey.get(keyId); + return row ? normalizeBindingRow(row) : undefined; + }, + }; +} diff --git a/middleware/src/mcp/publicMcpKeyBindingsAdmin.ts b/middleware/src/mcp/publicMcpKeyBindingsAdmin.ts new file mode 100644 index 00000000..9f748a17 --- /dev/null +++ b/middleware/src/mcp/publicMcpKeyBindingsAdmin.ts @@ -0,0 +1,387 @@ +/** + * W5-1 — the operator-facing WRITE half of `public_mcp_key_bindings`. + * + * The endpoint shipped in W2-3 (issue #542) is driven entirely by rows in that + * table, and nothing in the repo could create one: the read store's interface is + * `get(keyId)` and nothing else, `INSERT INTO public_mcp_key_bindings` had zero + * hits repo-wide, and the endpoint's own README told the consumer "you cannot + * change it yourself". The endpoint was therefore inert as shipped. This module + * is the missing writer. + * + * DELIBERATELY A SEPARATE MODULE FROM THE READER, AND A SEPARATE OBJECT. + * `publicMcpKeyBindings.ts` states the rule this file honours: the + * internet-facing endpoint "has no business holding a writer for its own + * authorization data". `wirePublicMcp.ts` takes `PublicMcpKeyBindingStore` — + * the read-only interface — and that must stay true. Nothing here is reachable + * from the public endpoint's dependency bag; the only consumer is the + * operator-session-gated router in `../routes/publicMcpBindingsRouter.ts`. A + * single store object exposing both halves would be one careless `deps.bindings` + * away from giving a third-party API key a write path to the table that decides + * what that key may do. + * + * VALIDATION IS THE READER'S, NOT A SECOND COPY. Every write is validated by + * running the candidate row through `normalizeBindingRow` — the exact function + * the enforcement path uses. Hand-rolling a second rule set here is how an + * operator ends up with a row the admin UI accepted and the endpoint silently + * ignores: a binding that appears configured and grants nothing. Two extra + * checks sit on top, and only because the reader cannot express them: + * - the `0..600` CHECK on `write_rate_limit_per_minute` (the reader accepts + * any non-negative integer; the DB would reject 601 with a 23514 that + * surfaces as a 500 rather than a 400); + * - `enabled: false`, which the reader resolves to `undefined` because a + * parked binding grants nothing — that is a legitimate stored state, not a + * validation failure, so it is carried alongside the validation rather than + * through it. + */ + +import type { Pool } from 'pg'; + +import { normalizeBindingRow } from './publicMcpKeyBindings.js'; + +/** The schema's `CHECK (write_rate_limit_per_minute BETWEEN 0 AND 600)`, + * mirrored so an out-of-range value is a 400 with a readable message rather + * than a constraint violation surfacing as a 500. */ +export const MAX_WRITE_RATE_LIMIT_PER_MINUTE = 600; +/** Migration `0033` default. Applied when the operator omits the field so the + * admin path and a bare `INSERT` agree on the same starting budget. */ +export const DEFAULT_WRITE_RATE_LIMIT_PER_MINUTE = 5; + +/** + * A stored row as an operator sees it — including the fact the runtime + * `PublicMcpKeyBinding` deliberately hides. + * + * `enabled` is absent from the runtime type because the endpoint must not be + * able to tell "parked" from "never configured" (both reach nothing). An + * operator needs exactly that distinction, which is why the admin row carries + * it and the runtime one does not. + */ +export interface PublicMcpKeyBindingAdminRow { + readonly keyId: string; + readonly agentId: string; + readonly readTools: readonly string[]; + readonly writeTools: readonly string[]; + readonly writeRateLimitPerMinute: number; + readonly enabled: boolean; + readonly createdAt: string; + readonly updatedAt: string; +} + +/** + * What an operator submits. Optional fields take the migration's defaults. + * + * `enabled` is the exception, and the asymmetry is the whole point: OMITTING it + * means "do not touch the parked/active state", not "activate". A binding is the + * entire authorization model for an internet-facing endpoint, and revoke is the + * incident response — a field the operator never mentioned must not be able to + * undo it. On a NEW row there is no prior state to preserve, so it starts + * `true`; on an existing row the stored value survives. Re-arming a revoked key + * therefore requires an explicit `enabled: true` (or the `/restore` route). + */ +export interface PublicMcpKeyBindingInput { + readonly keyId: string; + readonly agentId: string; + readonly readTools: readonly string[]; + readonly writeTools: readonly string[]; + readonly writeRateLimitPerMinute?: number; + /** Absent ⇒ preserve whatever the row says today (new rows start enabled). */ + readonly enabled?: boolean; +} + +/** + * The stored row plus whether this call CREATED it. + * + * The router needs the distinction to answer `201 Created` honestly. Returning + * "Created" for a write that overwrote an existing binding is not merely a + * cosmetic lie: it is the operator's only per-request signal that they landed on + * a row somebody else had already configured — or parked. + */ +export interface PublicMcpKeyBindingUpsertResult { + readonly binding: PublicMcpKeyBindingAdminRow; + readonly created: boolean; +} + +export interface BindingValidationFailure { + readonly code: string; + readonly message: string; +} + +export type BindingValidationResult = + | { readonly ok: true; readonly value: PublicMcpKeyBindingInput } + | { readonly ok: false; readonly error: BindingValidationFailure }; + +/** + * Writes bindings. Operator-only. + * + * Kept off `PublicMcpKeyBindingStore` on purpose — see the module doc comment. + */ +export interface PublicMcpKeyBindingAdminStore { + /** Every row, parked ones included. An operator reviewing what a key may do + * needs to see the disabled rows; the endpoint never does. */ + list(): Promise; + /** Creates or replaces the row for `input.keyId`. An absent `input.enabled` + * PRESERVES the stored flag rather than defaulting it — see the input type. */ + upsert(input: PublicMcpKeyBindingInput): Promise; + /** Parks (or un-parks) a binding without losing what it was configured to + * grant. `undefined` when there is no such row. */ + setEnabled(keyId: string, enabled: boolean): Promise; + /** Deletes the row outright. `false` when there was nothing to delete. */ + remove(keyId: string): Promise; +} + +/** + * Validates operator input by asking the READER whether it would accept the row + * this write would produce, and returns the normalized lists to persist. + * + * The normalization is not merely a check — its output is what gets stored. A + * tool named in both lists resolves to WRITE (`normalizeBindingRow` picks the + * stricter reading), and persisting that resolution means the row an operator + * reads back says exactly what the endpoint will enforce. Persisting the raw + * submission instead would leave a row whose `read_tools` names a tool that is + * in fact gated on `mcp:write:` — true but unreadable, and the kind of + * discrepancy that gets "fixed" in the wrong direction later. + * + * `enabled` is validated against `true` regardless of what the operator asked + * for: a parked row is a row the reader denies BY DESIGN, so running the check + * with the operator's `false` would reject every attempt to save a parked + * binding. + * + * It is also carried through UNTOUCHED — absent stays absent. The previous + * `input.enabled ?? true` here is what made revoke undoable: it turned "the + * submission said nothing about enabled" into "the submission asked for + * enabled", and the upsert then wrote that manufactured `true` over a row an + * operator had deliberately parked. Only the store knows the current state, so + * only the store may decide what "unspecified" resolves to. + */ +export function validateBindingInput(input: PublicMcpKeyBindingInput): BindingValidationResult { + const writeRateLimitPerMinute = + input.writeRateLimitPerMinute ?? DEFAULT_WRITE_RATE_LIMIT_PER_MINUTE; + if ( + !Number.isInteger(writeRateLimitPerMinute) || + writeRateLimitPerMinute < 0 || + writeRateLimitPerMinute > MAX_WRITE_RATE_LIMIT_PER_MINUTE + ) { + return { + ok: false, + error: { + code: 'write_rate_limit_out_of_range', + message: `writeRateLimitPerMinute must be an integer between 0 and ${String( + MAX_WRITE_RATE_LIMIT_PER_MINUTE, + )}`, + }, + }; + } + + const normalized = normalizeBindingRow({ + key_id: input.keyId, + agent_id: input.agentId, + read_tools: input.readTools, + write_tools: input.writeTools, + write_rate_limit_per_minute: writeRateLimitPerMinute, + enabled: true, + }); + if (!normalized) { + return { + ok: false, + error: { + code: 'binding_rejected_by_reader', + message: + 'the enforcement path would refuse this binding: keyId and agentId must be non-empty strings and both tool lists must hold only non-empty strings', + }, + }; + } + + return { + ok: true, + value: { + keyId: normalized.keyId, + agentId: normalized.agentId, + readTools: normalized.readTools, + writeTools: normalized.writeTools, + writeRateLimitPerMinute: normalized.writeRateLimitPerMinute, + ...(input.enabled === undefined ? {} : { enabled: input.enabled }), + }, + }; +} + +/** Raw row shape as pg hands it back. */ +interface AdminRowShape { + key_id: unknown; + agent_id: unknown; + read_tools: unknown; + write_tools: unknown; + write_rate_limit_per_minute: unknown; + enabled: unknown; + created_at: unknown; + updated_at: unknown; +} + +const SELECT_COLUMNS = + 'key_id, agent_id, read_tools, write_tools, write_rate_limit_per_minute, enabled, created_at, updated_at'; + +/** + * Shapes a stored row for the operator surface. + * + * Lenient where the reader is strict, and that asymmetry is deliberate: an + * unreadable row must still be VISIBLE to the operator, because a row nobody + * can see is a row nobody can repair. Nothing is granted by rendering it — the + * reader still denies the same row on the enforcement path. + */ +function toAdminRow(raw: AdminRowShape): PublicMcpKeyBindingAdminRow { + return { + keyId: String(raw.key_id), + agentId: String(raw.agent_id), + readTools: toStringList(raw.read_tools), + writeTools: toStringList(raw.write_tools), + writeRateLimitPerMinute: Number(raw.write_rate_limit_per_minute), + enabled: raw.enabled === true, + createdAt: toIsoString(raw.created_at), + updatedAt: toIsoString(raw.updated_at), + }; +} + +function toStringList(raw: unknown): readonly string[] { + return Array.isArray(raw) ? raw.filter((e): e is string => typeof e === 'string') : []; +} + +function toIsoString(raw: unknown): string { + if (raw instanceof Date) return raw.toISOString(); + return typeof raw === 'string' ? raw : ''; +} + +/** + * Postgres-backed writer. + * + * `updated_at` is set EXPLICITLY on every mutating statement. Migration `0033` + * gives the column `DEFAULT now()` and NO trigger, so the default fires on + * INSERT and never again — an `ON CONFLICT DO UPDATE` that omits it leaves the + * column frozen at creation time, and the one question the column exists to + * answer ("when was this integration's reach last changed?") then silently + * answers wrong. + */ +export function createPublicMcpKeyBindingAdminStore(pool: Pool): PublicMcpKeyBindingAdminStore { + return { + async list() { + const { rows } = await pool.query( + `SELECT ${SELECT_COLUMNS} + FROM public_mcp_key_bindings + ORDER BY created_at DESC, key_id ASC`, + ); + return (rows as AdminRowShape[]).map(toAdminRow); + }, + + async upsert(input) { + // `enabled` binds NULL when the operator did not mention it, and the two + // branches then resolve that NULL differently — `true` on insert (a new + // binding has no prior state), the CURRENT COLUMN on conflict. + // + // Note it is `public_mcp_key_bindings.enabled` and NOT `EXCLUDED.enabled` + // in the DO UPDATE branch: EXCLUDED holds the row this statement PROPOSED, + // so coalescing against it would resolve back to the insert's `true` and + // re-arm the very binding this is meant to leave parked. + // + // `(created_at = updated_at)` is the created/updated discriminator. Both + // columns resolve to `now()` — the transaction timestamp — on the insert + // branch, while the conflict branch moves only `updated_at` and leaves + // `created_at` at an earlier transaction's clock. That uses documented + // `now()` semantics rather than the usual `xmax = 0` idiom, which reads a + // storage-layer detail that also moves when an unrelated transaction holds + // a row lock. + const { rows } = await pool.query( + `INSERT INTO public_mcp_key_bindings + (key_id, agent_id, read_tools, write_tools, write_rate_limit_per_minute, enabled, updated_at) + VALUES ($1, $2, $3, $4, $5, COALESCE($6::boolean, true), now()) + ON CONFLICT (key_id) DO UPDATE SET + agent_id = EXCLUDED.agent_id, + read_tools = EXCLUDED.read_tools, + write_tools = EXCLUDED.write_tools, + write_rate_limit_per_minute = EXCLUDED.write_rate_limit_per_minute, + enabled = COALESCE($6::boolean, public_mcp_key_bindings.enabled), + updated_at = now() + RETURNING ${SELECT_COLUMNS}, (created_at = updated_at) AS inserted`, + [ + input.keyId, + input.agentId, + input.readTools, + input.writeTools, + input.writeRateLimitPerMinute ?? DEFAULT_WRITE_RATE_LIMIT_PER_MINUTE, + input.enabled ?? null, + ], + ); + const raw = rows[0] as AdminRowShape & { inserted?: unknown }; + return { binding: toAdminRow(raw), created: raw.inserted === true }; + }, + + async setEnabled(keyId, enabled) { + const { rows } = await pool.query( + `UPDATE public_mcp_key_bindings + SET enabled = $2, updated_at = now() + WHERE key_id = $1 + RETURNING ${SELECT_COLUMNS}`, + [keyId, enabled], + ); + const row = rows[0] as AdminRowShape | undefined; + return row ? toAdminRow(row) : undefined; + }, + + async remove(keyId) { + const { rowCount } = await pool.query( + 'DELETE FROM public_mcp_key_bindings WHERE key_id = $1', + [keyId], + ); + return (rowCount ?? 0) > 0; + }, + }; +} + +/** + * In-memory writer, for tests and for a DATABASE_URL-less install. + * + * `now` is injectable because the invariant worth testing — that `updated_at` + * MOVES on an update — is unobservable when two writes land inside the same + * millisecond, which they routinely do in a unit test. + */ +export function createInMemoryPublicMcpKeyBindingAdminStore( + seed: readonly PublicMcpKeyBindingAdminRow[] = [], + now: () => Date = () => new Date(), +): PublicMcpKeyBindingAdminStore { + const byKey = new Map(seed.map((r) => [r.keyId, r])); + return { + async list() { + return Array.from(byKey.values()); + }, + async upsert(input) { + const stamp = now().toISOString(); + const existing = byKey.get(input.keyId); + const row: PublicMcpKeyBindingAdminRow = { + keyId: input.keyId, + agentId: input.agentId, + readTools: [...input.readTools], + writeTools: [...input.writeTools], + writeRateLimitPerMinute: + input.writeRateLimitPerMinute ?? DEFAULT_WRITE_RATE_LIMIT_PER_MINUTE, + // Mirrors the SQL's `COALESCE($6, public_mcp_key_bindings.enabled)`: an + // unspecified flag preserves the stored state, and only a row that does + // not exist yet falls through to `true`. + enabled: input.enabled ?? existing?.enabled ?? true, + createdAt: existing?.createdAt ?? stamp, + updatedAt: stamp, + }; + byKey.set(row.keyId, row); + return { binding: row, created: existing === undefined }; + }, + async setEnabled(keyId, enabled) { + const existing = byKey.get(keyId); + if (!existing) return undefined; + const row: PublicMcpKeyBindingAdminRow = { + ...existing, + enabled, + updatedAt: now().toISOString(), + }; + byKey.set(keyId, row); + return row; + }, + async remove(keyId) { + return byKey.delete(keyId); + }, + }; +} diff --git a/middleware/src/mcp/publicMcpPath.ts b/middleware/src/mcp/publicMcpPath.ts new file mode 100644 index 00000000..88952a6b --- /dev/null +++ b/middleware/src/mcp/publicMcpPath.ts @@ -0,0 +1,29 @@ +/** + * W2-3 (issue #542) — the ONE definition of the public MCP endpoint's path. + * + * Its own module, dependency-free on purpose. `auth/publicPaths.ts` imports it + * to build the requireAuth exemption, and `index.ts` imports it to mount the + * router; if this constant lived next to the server implementation, importing + * it would drag the MCP SDK into `publicPaths.ts`'s import graph (and into + * every test that asserts against the allowlist) for the sake of one string. + * + * The reason it is a shared constant at all is recorded at the top of + * `auth/publicPaths.ts`: epic #470's runner router was mounted without a + * session guard and its e2e test built a bare `express()` app to prove it, so + * the test passed while the route 401'd in production behind the blanket `/api` + * guard. A retyped path is that same bug with a different name. + */ + +/** Where the public, stateless, API-key-authenticated MCP server is mounted. */ +export const PUBLIC_MCP_PATH = '/api/v1/mcp'; + +/** + * Denormalized `server_name` for the `mcp_call_log` rows this endpoint writes. + * + * Public MCP calls have no upstream MCP server — omadia IS the server here, and + * the call goes inward to a local tool rather than outward to a vendor. The + * `server_id` FK stays NULL (0009 made it nullable precisely so audit rows + * survive without a server row) and this literal is what an operator sees in + * the call-log UI's Server column. + */ +export const PUBLIC_MCP_SERVER_NAME = 'omadia-public-mcp'; diff --git a/middleware/src/mcp/publicMcpPrivacy.ts b/middleware/src/mcp/publicMcpPrivacy.ts new file mode 100644 index 00000000..a33b15f3 --- /dev/null +++ b/middleware/src/mcp/publicMcpPrivacy.ts @@ -0,0 +1,150 @@ +/** + * W2-3 (issue #542) — the public MCP endpoint's privacy posture. + * + * ─── The decision this module exists to make ───────────────────────────────── + * + * The dispatch privacy seam is CLOSED: `ToolDispatchService` now replicates the + * chat path's data-plane boundary (raw capture → intern-exemption → operator + * bypass + receipt → intern). But it closed it at PARITY with the chat path, and + * its own comment hands one consequence to this issue: + * + * // Fail-OPEN, matching `Orchestrator.dispatchToolDeadlined` exactly. This + * // is parity, not an endorsement: for a PUBLIC endpoint a masking failure + * // that emits raw rows is a leak, and a fail-CLOSED policy for untrusted + * // callers is worth its own decision (#542) … + * + * DECISION: the public endpoint fails CLOSED. Three separate fail-open paths + * exist between a tool's raw result and an internet caller, and this module + * closes all three — WITHOUT changing `toolDispatchService.ts`, so the chat + * path's behaviour is untouched and the sibling unit's parity argument stands. + * + * 1. **Masking throws.** The dispatcher catches and returns the raw result. + * Closed by wrapping `internToolResultV4` so it never throws: on failure it + * records the failure and returns a placeholder digest. The dispatcher's + * fail-open branch is therefore never reached, and the endpoint discards the + * result entirely. Not a nicety — the failure mode being defended against is + * "the privacy provider is having a bad minute and every Odoo row goes out + * over HTTP to a third party". + * + * 2. **Operator per-plugin bypass.** `checkBypass` returning a pluginId means + * raw passthrough. That setting was made for internal/chat use by an + * operator who was not being asked "…and also to anonymous API callers?". + * Closed by pinning `checkBypass` to `undefined`: a bypass does not extend + * to this endpoint, ever, and cannot be configured to. + * + * 3. **Intern-exempt tools.** `isInternExemptTool` hands `memory`, + * `read_attachment`, `query_processes`, `ask_user_choice` and friends over + * IN CLEAR, by design — masking them blinds the agent to its own state. That + * reasoning is about the AGENT reading its own scaffolding; it does not + * survive contact with a third party reading it over HTTP. This one cannot + * be closed from the handle (the dispatcher checks it BEFORE consulting the + * handle), so it is closed at the allowlist instead: see + * `isPubliclyServableTool` and its use in `PublicMcpServer`. + * + * A fourth path — no privacy provider installed at all, so results flow through + * unchanged — is closed in `PublicMcpServer` by refusing the call. + */ + +import type { PrivacyTurnHandle } from '@omadia/orchestrator'; +import { isInternExemptTool } from '@omadia/orchestrator'; + +/** + * Never reaches a caller: the endpoint checks `maskingFailed()` and replaces the + * whole result. It exists only so the wrapper can satisfy the handle's return + * type without throwing (a throw would hit the dispatcher's fail-OPEN branch and + * emit the raw rows — the exact leak this module prevents). + */ +export const MASKING_FAILED_PLACEHOLDER = '[omadia:public-mcp:masking-failed]'; + +export interface PublicMcpPrivacyGate { + /** Hand this to `ToolDispatchService`'s `privacy` dependency. */ + readonly handle: PrivacyTurnHandle; + /** True when masking failed during this dispatch — DISCARD the result. */ + maskingFailed(): boolean; + /** + * True when masking RAN and produced a digest for this dispatch. + * + * The positive signal, and the one `PublicMcpServer` actually gates on: + * `maskingFailed()` is false both when masking succeeded and when it never + * ran, and "never ran" is the shape every leak in this family has taken. A + * dispatch branch that skips the boundary, a handler returning a non-string + * the masker declines to walk, an intern-exempt name that slipped the + * allowlist — all of them leave `maskingFailed()` false with raw bytes in + * hand. + * + * Enforced, not advisory: a result the gate did not mask is discarded. See + * the assertion in `PublicMcpServer.callToolFor`, and + * `publicMcpMaskingAssertion.test.ts` for what it catches. + */ + masked(): boolean; +} + +/** + * Wraps a real handle so masking cannot fail open, and an operator bypass cannot + * reach a public caller. + * + * One gate per DISPATCH, not per process — `maskingFailed()` is per-call state, + * and a shared gate would make one caller's masking failure discard another + * caller's perfectly good result (or, far worse, let a stale `false` clear a + * failure that did happen). + */ +export function createFailClosedPrivacyGate(base: PrivacyTurnHandle): PublicMcpPrivacyGate { + let failed = false; + let didMask = false; + + const handle: PrivacyTurnHandle = { + ...base, + + async internToolResultV4(input) { + try { + const result = await base.internToolResultV4(input); + didMask = true; + return result; + } catch (err) { + failed = true; + // Logged, not rethrown. Rethrowing would reach the dispatcher's + // fail-open catch, which returns `rawResult` — i.e. the leak. + console.warn( + `[public-mcp] privacy masking FAILED for tool \`${input.toolName}\` — refusing the call (fail-closed):`, + err, + ); + return { digestText: MASKING_FAILED_PLACEHOLDER, datasetId: '' }; + } + }, + + /** + * Pinned off. An operator's per-plugin `_privacy_mode: bypass` is a decision + * about their own agent's chat behaviour; nobody consented to extending it + * to an unauthenticated-origin HTTP caller. Returning `undefined` + * unconditionally means the dispatcher always takes the intern branch, so + * `recordBypassedTool` is never reached from this path either. + */ + checkBypass(): undefined { + return undefined; + }, + }; + + return { + handle, + maskingFailed: () => failed, + masked: () => didMask, + }; +} + +/** + * Whether a tool may EVER be served over the public endpoint, independent of any + * operator allowlist. + * + * The only rule today is the intern exemption (see this module's header, point + * 3): a tool whose result the Privacy Shield deliberately hands over in clear + * must not be reachable by a third party, no matter what an operator typed into + * a binding row. `memory` alone would expose the agent's working memory — + * arbitrary accumulated business context — to whoever holds the key. + * + * Enforced as a hard filter rather than a warning, because the alternative is a + * log line nobody reads guarding a data leak. An operator who lists one gets the + * warning AND the tool stays unreachable. + */ +export function isPubliclyServableTool(name: string): boolean { + return !isInternExemptTool(name); +} diff --git a/middleware/src/mcp/publicMcpRouter.ts b/middleware/src/mcp/publicMcpRouter.ts new file mode 100644 index 00000000..4bc1efb6 --- /dev/null +++ b/middleware/src/mcp/publicMcpRouter.ts @@ -0,0 +1,72 @@ +/** + * W2-3 (issue #542) — mounts the public MCP server as an express router. + * + * Split from `PublicMcpServer` so the protocol/authorization logic is testable + * without an HTTP stack, and so the WIRING (which middleware, in which order) + * is one short readable file. The order below is load-bearing: + * + * 1. `bodyCapMiddleware` — cheapest rejection, and it must run before any + * work is attributed to a key. + * 2. `requireApiKey` — authentication + the general per-key rate limit + + * the `mcp:invoke`-class scope floor. Answers 401 on + * a missing/invalid key and 403 on a scope miss. + * 3. the MCP handler — per-request stateless transport, per-key allowlist. + * + * `requireApiKey` is given NO `scope` option on purpose. A single scope check + * here would have to be either `mcp:list` or `mcp:invoke`, and whichever were + * chosen would 403 the other legitimate request shape. Both are checked inside + * the JSON-RPC handlers, where the method being invoked is known. The 403-on- + * scope-miss behavior is still exercised — see `requireApiKey`'s own tests and + * the endpoint tests for the per-method gates. + */ + +import { Router } from 'express'; + +import type { ApiKeyStore, AuditLog, RateLimiter } from '@omadia/api-key-auth'; +import { requireApiKey } from '@omadia/api-key-auth'; + +import { PUBLIC_MCP_PATH } from './publicMcpPath.js'; +import { PublicMcpServer, type PublicMcpServerDeps } from './publicMcpServer.js'; + +/** + * The router handles its mount root, NOT an absolute path. + * + * The caller mounts it at `PUBLIC_MCP_PATH`, so this is `'/'`. Baking the + * absolute path in here as well would make `app.use(PUBLIC_MCP_PATH, router)` + * resolve to `/api/v1/mcp/api/v1/mcp` — and the version that "works", + * `app.use(router)`, would apply the router's own middleware (including + * `requireAuth`, which the caller pairs it with) to EVERY request on the app. + */ +const ROUTER_ROOT = '/'; + +export interface PublicMcpRouterDeps extends PublicMcpServerDeps { + /** The SAME store the operator mints keys with. Reused rather than + * duplicated: a second key store would be a second place to revoke. */ + readonly apiKeys: ApiKeyStore; + /** General per-key budget, applied by `requireApiKey` to every request + * including `tools/list`. Distinct from `writeRateLimiter`. */ + readonly rateLimiter?: RateLimiter; + /** `@omadia/api-key-auth`'s own vault-backed usage trail. Complementary to + * the `mcp_call_log` rows the audit sink writes: this one records HTTP + * outcomes per key, that one records tool calls. */ + readonly keyAuditLog?: AuditLog; +} + +export function createPublicMcpRouter(deps: PublicMcpRouterDeps): Router { + const server = new PublicMcpServer(deps); + const router = Router(); + + router.use( + ROUTER_ROOT, + server.bodyCapMiddleware(), + requireApiKey({ + apiKeys: deps.apiKeys, + ...(deps.rateLimiter ? { rateLimiter: deps.rateLimiter } : {}), + ...(deps.keyAuditLog ? { auditLog: deps.keyAuditLog } : {}), + routeLabel: PUBLIC_MCP_PATH, + }), + server.handler(), + ); + + return router; +} diff --git a/middleware/src/mcp/publicMcpServer.ts b/middleware/src/mcp/publicMcpServer.ts new file mode 100644 index 00000000..c98b3541 --- /dev/null +++ b/middleware/src/mcp/publicMcpServer.ts @@ -0,0 +1,800 @@ +/** + * W2-3 (issue #542) — the public, stateless, API-key-authenticated MCP server. + * + * ─── Why this is a new class and not a flag on `LoopbackMcpServer` ─────────── + * + * `LoopbackMcpServer` binds `127.0.0.1` on an ephemeral port and authenticates + * ONE static bearer with a constant-time compare. Its own security note states + * the trust boundary it was designed for: "any local process that can read the + * 0600 mcp-config bearer can call omadia's tools — a local-process trust + * boundary." Not one clause of that transfers to an internet-facing route. + * There is no single bearer, no local-process assumption, no "the token is the + * whole authorization", and no acceptable version of "sees the FULL native tool + * registry" (which is what the loopback path documents itself as doing). A flag + * would leave both behaviors in one class where the dangerous default is one + * boolean away from every caller. + * + * What IS shared is the stateless-transport lifecycle, and that part is copied + * deliberately rather than reinvented — see `createRequestScopedServer`. + * + * ─── The authorization model ──────────────────────────────────────────────── + * + * Four independent gates, all default-deny, in this order: + * + * 1. AUTHENTICATION — `requireApiKey` (mounted by the router, not here) does + * the constant-time hash compare and answers 401. It deliberately does not + * populate `req.session`; that is preserved, and nothing here reads it. + * 2. BINDING — the key must have an enabled `public_mcp_key_bindings` row. + * No row ⇒ zero tools. The row names ONE agent, which is what makes key A + * unable to reach agent B's tools even though the native tool registry is + * process-wide. + * 3. ALLOWLIST — the tool must be named in that row AND advertised by the + * agent. Enforced on `tools/call` AND on `tools/list` through the SAME + * predicate (`callableToolNames`), because a tool name the key cannot call + * is itself a disclosure (it tells a third party which integrations this + * install runs), and two predicates that disagree hand a caller a working + * oracle for the binding's contents. + * 4. SCOPE — `mcp:list` to enumerate, `mcp:invoke` to call, and additionally + * the exact `mcp:write:` for anything the row lists as a write. + * `WILDCARD_SCOPE` does not satisfy a write scope; `hasScope` enforces that + * for every caller. + * + * `tools/list` returns exactly the set the key could successfully CALL — not + * "everything it may see". A key holding `mcp:list` but not `mcp:invoke` gets + * an empty list, and a write tool appears only when its per-tool write scope is + * present. Any looser rule turns the list into an inventory of what to attack. + */ + +import { randomUUID } from 'node:crypto'; + +import type { Request, RequestHandler, Response } from 'express'; +import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + ErrorCode, + ListToolsRequestSchema, + McpError, +} from '@modelcontextprotocol/sdk/types.js'; + +import type { ApiKeyPrincipal, RateLimiter } from '@omadia/api-key-auth'; +import { hasScope, hasWriteScope, MCP_INVOKE_SCOPE, MCP_LIST_SCOPE } from '@omadia/api-key-auth'; +import type { + DispatchableToolSpec, + PrivacyTurnHandle, + ToolDispatchOptions, + ToolDispatchResult, +} from '@omadia/orchestrator'; + +import type { PublicMcpKeyBinding, PublicMcpKeyBindingStore } from './publicMcpKeyBindings.js'; +import { + createFailClosedPrivacyGate, + isPubliclyServableTool, + type PublicMcpPrivacyGate, +} from './publicMcpPrivacy.js'; + +/** Mirrors `LoopbackMcpServer`'s ceiling. See `enforceBodyCap` for why it is + * re-checked here instead of being handed to `express.json`. */ +export const MAX_REQUEST_BYTES = 8 * 1024 * 1024; + +/** Per-tool wall clock. A public caller must not be able to pin a dispatch + * slot open indefinitely; without this, `maxConcurrentCalls` below is a + * denial-of-service budget rather than a protection. */ +export const DEFAULT_TOOL_TIMEOUT_MS = 30_000; + +/** Process-wide ceiling on tool calls in flight from this endpoint. Tools reach + * Odoo/M365/Confluence and the LLM providers; an unbounded public fan-in + * starves the operator-facing chat path that shares those pools. */ +export const DEFAULT_MAX_CONCURRENT_CALLS = 4; + +/** + * The subset of `ToolDispatchService` this server uses. + * + * Structural rather than the concrete class so the wiring can supply a + * per-agent dispatcher without this module importing the orchestrator's + * construction path — and so tests exercise the real gates against a fake + * dispatcher instead of a whole orchestrator. + * + * `isWriteCapable` is the dispatch layer's OWN predicate + * (`isWriteCapableTool(writeCapabilities)`): declaration-driven, never derived + * from a tool's name. It is the source of truth for "may this mutate data", + * and this endpoint reads it rather than inventing a second answer. + */ +export interface PublicMcpDispatcher { + dispatch( + name: string, + input: unknown, + options?: ToolDispatchOptions, + ): Promise; + listDispatchableToolSpecs(): readonly DispatchableToolSpec[]; + isWriteCapable(name: string): boolean; + /** + * Runs `fn` with `handle` installed as the dispatcher's privacy dependency. + * + * `ToolDispatchService` takes `privacy` as a per-SERVICE dependency, but this + * endpoint needs a per-CALL handle: the fail-closed gate carries per-call + * state (`maskingFailed()`), and a shared one would let one caller's masking + * failure discard another caller's good result. The wiring builds the + * dispatcher around a mutable slot and exposes this to fill it for exactly the + * duration of one dispatch — see `wirePublicMcp.ts`. + * + * Optional in the type, but LOAD-BEARING in practice whenever a privacy + * provider is installed: the endpoint gates on `gate.masked()`, and a + * dispatcher that never receives the gate's handle can never set it. A host + * that omits this while masking is required has every call refused — loudly, + * which is the correct direction for a privacy control. + */ + withPrivacy?(handle: PrivacyTurnHandle, fn: () => Promise): Promise; +} + +/** One audit row per call, written by the wiring. Mirrors the vocabulary the + * base branch established on `mcp_call_log` (`actingIdentity`, with the + * literal `unresolved` for an identity that could not be established). */ +export interface PublicMcpAuditEntry { + readonly keyId: string; + readonly agentId: string; + readonly toolName: string; + readonly ok: boolean; + readonly error: string | null; + readonly durationMs: number; + readonly calledAt: Date; + readonly actingIdentity: string; + readonly write: boolean; +} + +/** Fire-and-forget. Implementations MUST NOT throw — an audit failure must + * never fail a caller's request, and must never be the reason a call + * succeeds either. */ +export type PublicMcpAuditSink = (entry: PublicMcpAuditEntry) => void; + +export interface PublicMcpServerDeps { + /** + * Resolves the dispatcher for ONE agent. `undefined` when that agent is not + * currently active — which fails the call closed rather than falling back to + * any other agent's dispatcher. + */ + readonly resolveDispatcher: (agentId: string) => PublicMcpDispatcher | undefined; + readonly bindings: PublicMcpKeyBindingStore; + /** + * Budget for WRITES only, separate from the general per-key limiter + * `requireApiKey` already applies. Two limiter instances, not one shared + * bucket: reads are cheap and idempotent, writes are neither, and a + * read-heavy integration's unused read headroom must not fund a write burst. + */ + readonly writeRateLimiter: RateLimiter; + readonly audit?: PublicMcpAuditSink; + /** + * Builds the privacy data-plane handle for ONE dispatch. + * + * REQUIRED in practice — see `requirePrivacyMasking`. This endpoint runs + * entirely outside `turnContext.run(...)`, so `ToolDispatchService`'s ambient + * fallback resolves to `undefined` here and results would flow to the caller + * with PII intact. The handle must be supplied explicitly; the scope ids are + * per-request because the Privacy Shield keys its Dataset Store on + * `(sessionId, turnId)` and sharing them across callers would let one + * caller's digest resolve against another's rows. + * + * Returns `undefined` when no `privacyRedact` provider is installed at all. + */ + readonly privacy?: (scope: { sessionId: string; turnId: string }) => PrivacyTurnHandle | undefined; + /** + * Whether a tool call is refused when masking is unavailable or fails. + * + * DEFAULTS TO TRUE. Three fail-open paths sit between a raw tool result and an + * internet caller, and `publicMcpPrivacy.ts` documents how each is closed. The + * one this flag governs is the coarsest: no privacy provider installed ⇒ + * `ToolDispatchService` passes results through unchanged, by design and in + * parity with the chat path. For an operator's own chat that is an accepted + * configuration; for a third party over HTTP it is a data leak with a + * config-shaped cause. + * + * Set false ONLY on a deliberate, documented operator decision — e.g. an + * install whose allowlisted tools provably carry no personal data. + */ + readonly requirePrivacyMasking?: boolean; + readonly serverName?: string; + readonly serverVersion?: string; + readonly toolTimeoutMs?: number; + readonly maxConcurrentCalls?: number; +} + +/** Deliberately identical for "no such tool", "not allowlisted for this key", + * and "allowlisted but the agent does not advertise it". Distinguishing them + * would confirm a tool's existence — or a binding's contents — to a caller not + * entitled to know, which is the same disclosure `tools/list` filtering exists + * to prevent. */ +function unavailableToolMessage(name: string): string { + return `Tool \`${name}\` is not available to this API key.`; +} + +export class PublicMcpServer { + private inFlight = 0; + + constructor(private readonly deps: PublicMcpServerDeps) {} + + private get toolTimeoutMs(): number { + return this.deps.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS; + } + + private get maxConcurrentCalls(): number { + return this.deps.maxConcurrentCalls ?? DEFAULT_MAX_CONCURRENT_CALLS; + } + + private get privacyMaskingRequired(): boolean { + return this.deps.requirePrivacyMasking ?? true; + } + + /** + * Whether a call to `name` may MUTATE data, and therefore needs the per-tool + * write scope, the tighter write budget, and at-most-once protection. + * + * The UNION of two sources, and the union is the security-relevant part: + * + * - `dispatcher.isWriteCapable(name)` — the dispatch layer's own + * declaration-driven predicate (`isWriteCapableTool`). Authoritative when a + * tool declares `writeCapabilities`, and the right source of truth: a + * name-derived guess is the silent-rollback failure the contract exists to + * prevent. + * - `binding.writeTools.includes(name)` — the operator's declaration. + * + * Neither alone is safe. `isWriteCapable` returns FALSE for an unannotated + * write tool (its own docs call that a plugin bug, but a plugin bug must not + * become a public write escalation), so the operator list covers it. And the + * operator list can put a tool under `read_tools` by mistake, which the + * annotation then overrides — a tool that declares it mutates data is a write + * even if the binding says otherwise. Union, so a MISTAKE IN EITHER DIRECTION + * fails toward "treat it as a write". + */ + private isEffectiveWrite( + dispatcher: PublicMcpDispatcher, + binding: PublicMcpKeyBinding, + name: string, + ): boolean { + return dispatcher.isWriteCapable(name) || binding.writeTools.includes(name); + } + + /** + * Enforces the 8 MB ceiling. + * + * NOT `express.json({ limit })`. The kernel mounts a global + * `express.json({ limit: '10mb' })` before every `/api` router (index.ts), so + * by the time a request reaches this one the stream is already consumed and + * parsed: a route-level parser would be a silent no-op and the loopback + * server's 8 MB ceiling would quietly become the kernel's 10 MB one. + * + * Mounting this router BEFORE `express.json` would allow a real streaming + * cap, but would also put it in front of the `/api` requireAuth mount and + * throw away the `publicPaths` half of the defense this route is required to + * use. The cap is the cheaper thing to reimplement. + * + * Two checks, and they are NOT two independent gates — be precise about which + * does the work: + * + * - The re-serialized body length is the ACTUAL enforcement. It catches + * every oversized body, including a chunked upload carrying no + * `Content-Length` at all. + * - `Content-Length` is a COST optimization in front of it: re-serializing + * an 8 MB body to measure it is itself expensive, and a client that + * honestly declares an oversized payload can be refused without paying + * that. It is not a security control on its own — a lying header cannot be + * trusted, and one that declares 9 MB while sending 100 bytes never + * reaches this middleware anyway (`express.json` is still waiting for the + * rest of the body). Treat it as the fast path, not as the gate. + */ + bodyCapMiddleware(): RequestHandler { + return (req: Request, res: Response, next): void => { + const declared = Number(req.headers['content-length']); + const declaredTooLarge = Number.isFinite(declared) && declared > MAX_REQUEST_BYTES; + const actualTooLarge = + req.body !== undefined && Buffer.byteLength(JSON.stringify(req.body) ?? '', 'utf8') > MAX_REQUEST_BYTES; + if (declaredTooLarge || actualTooLarge) { + res.status(413).json({ + jsonrpc: '2.0', + error: { code: 413, message: 'Payload Too Large' }, + id: null, + }); + return; + } + next(); + }; + } + + /** The express handler. Mount behind `requireApiKey`, which is what + * guarantees `req.apiKey` is present. */ + handler(): RequestHandler { + return (req: Request, res: Response): void => { + void this.handleHttp(req, res); + }; + } + + private async handleHttp(req: Request, res: Response): Promise { + // POST only, for the same two reasons `LoopbackMcpServer` gives: the MCP + // spec makes the standalone GET SSE stream optional and blesses 405 when a + // server does not offer one, and — decisive here — a per-request transport + // LEAKS on GET, because an SSE stream never ends, so `handleRequest` never + // resolves and the `finally` that tears the pair down never runs. + if (req.method !== 'POST') { + res.status(405).set('Allow', 'POST').json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method Not Allowed' }, + id: null, + }); + return; + } + + const principal = req.apiKey; + if (!principal) { + // Unreachable behind `requireApiKey`. Answering 401 rather than throwing + // means a future mis-mount degrades to "authentication required" instead + // of to an unauthenticated 500 that still ran the handler. + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null, + }); + return; + } + + let session: ReturnType | undefined; + try { + session = this.createRequestScopedServer(principal); + await session.mcp.connect(session.transport); + await session.transport.handleRequest(req, res, req.body); + } catch (error) { + if (res.headersSent) { + res.end(); + return; + } + // No error detail on the wire: this is a public surface, and a dispatch + // stack trace names internal tools, plugins and hosts. + console.warn(`[public-mcp] request failed: ${String(error)}`); + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null, + }); + } finally { + // A stateless transport is SINGLE-USE — the SDK throws "Stateless + // transport cannot be reused across requests" on its second use — so + // dropping it here is what makes the next request work at all. Safe at + // this point because `enableJsonResponse` means the response is fully + // written by the time `handleRequest` resolves. + await session?.transport.close().catch(() => {}); + await session?.mcp.close().catch(() => {}); + } + } + + /** + * A fresh `Server` + transport pair for ONE HTTP request. + * + * `sessionIdGenerator: undefined` selects the SDK's stateless mode: no + * session id is issued, no session validation happens, and a client may skip + * the `initialize` handshake and never send `Mcp-Session-Id`. That is the + * whole premise of the issue — horizontal scalability requires that any + * process can answer any request — and it is why the pair is per-request by + * construction rather than by convention: a shared transport makes only the + * FIRST request work and 500s every one after it. + */ + private createRequestScopedServer(principal: ApiKeyPrincipal): { + mcp: McpServer; + transport: StreamableHTTPServerTransport; + } { + const mcp = new McpServer( + { + name: this.deps.serverName ?? 'omadia-public-mcp', + version: this.deps.serverVersion ?? '0.0.0', + }, + { capabilities: { tools: {} } }, + ); + + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: await this.listToolsFor(principal), + })); + + mcp.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + // MCP standardizes no idempotency field, so it rides in `params._meta`, + // the spec's designated passthrough. Advisory by construction — see the + // idempotency section of the endpoint README for what a consumer may + // actually rely on. + const meta = request.params._meta as { idempotencyKey?: unknown } | undefined; + const idempotencyKey = + typeof meta?.idempotencyKey === 'string' && meta.idempotencyKey.length > 0 + ? meta.idempotencyKey + : undefined; + const result = await this.callToolFor(principal, name, args ?? {}, idempotencyKey); + return { + content: [{ type: 'text' as const, text: result.content }], + ...(result.isError ? { isError: true } : {}), + }; + }); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + + return { mcp, transport }; + } + + /** + * The tools this key can actually CALL, name-sorted. + * + * Not "the tools it may see" — the two ARE the same set, because both come + * from `callableToolNames`. A name the caller cannot invoke is a free hint + * about which integrations this install runs, which is exactly the enumeration + * the issue's own security notes warn about. + */ + private async listToolsFor( + principal: ApiKeyPrincipal, + ): Promise<{ name: string; description: string; inputSchema: unknown }[]> { + if (!hasScope(principal.scopes, MCP_LIST_SCOPE)) { + // An error, not an empty list: "not scoped for mcp:list" leaks no tool + // names, and an integrator debugging a misconfigured key deserves to be + // able to tell a scope problem from an empty allowlist. + throw new McpError( + ErrorCode.InvalidRequest, + `this API key is not scoped for '${MCP_LIST_SCOPE}'`, + ); + } + + const binding = await this.deps.bindings.get(principal.keyId); + if (!binding) return []; + + const dispatcher = this.deps.resolveDispatcher(binding.agentId); + if (!dispatcher) return []; + + const callable = this.callableToolNames(principal, binding, dispatcher); + if (callable.size === 0) return []; + + // `callable` is already a SUBSET of what the agent advertises (see + // `callableToolNames`), so this filter only projects the specs — it can no + // longer narrow the set. Kept as the projection step, not as a second + // predicate: describing a tool requires its spec, and the spec is what this + // iteration is here to fetch. + return dispatcher + .listDispatchableToolSpecs() + .filter((spec) => callable.has(spec.name)) + .map((spec) => ({ + name: spec.name, + description: spec.description, + inputSchema: spec.input_schema, + })) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + } + + /** + * The exact set of tool names this key could successfully invoke. + * + * ONE function, used by both `tools/list` and `tools/call`, so the list can + * never advertise something the call path would refuse (or vice versa — + * which would be the security-relevant direction). + * + * ─── W4: the ADVERTISED filter belongs here, not only in `listToolsFor` ───── + * + * `tools/list` used to intersect this set with `listDispatchableToolSpecs()` + * while `tools/call` authorized on this set alone and then dispatched by raw + * name. A tool named in a binding but NOT advertised by the agent was + * therefore hidden from list yet accepted by call — where it failed deep in + * the dispatcher as ``Error: unknown tool `x` `` instead of the uniform + * refusal. List being stricter is the safe direction, but the two error + * shapes are a working oracle: a caller can distinguish "in your binding but + * unavailable" from "not in your binding" and map the binding's contents one + * probe at a time. Moving the filter here collapses both to + * `unavailableToolMessage`, and makes the invariant documented above actually + * true. + */ + private callableToolNames( + principal: ApiKeyPrincipal, + binding: PublicMcpKeyBinding, + dispatcher: PublicMcpDispatcher, + ): ReadonlySet { + if (!hasScope(principal.scopes, MCP_INVOKE_SCOPE)) return new Set(); + const advertised = new Set( + dispatcher.listDispatchableToolSpecs().map((spec) => spec.name), + ); + const callable = new Set(); + for (const tool of [...binding.readTools, ...binding.writeTools]) { + // The agent must actually offer it. Covers a stale binding, a plugin + // whose readiness gate is closed (`listDispatchableToolSpecs` filters + // those), and a handler-only registration that carries no spec. + if (!advertised.has(tool)) continue; + // Never servable regardless of the operator's allowlist: a tool the + // Privacy Shield deliberately exempts from masking would reach a third + // party in clear. See `publicMcpPrivacy.ts` header, point 3. + if (!isPubliclyServableTool(tool)) continue; + if (this.isEffectiveWrite(dispatcher, binding, tool)) { + // Per-tool, and wildcard-proof: `hasWriteScope` routes through + // `hasScope`, which refuses to let `*` satisfy a `mcp:write:` scope. + if (hasWriteScope(principal.scopes, tool)) callable.add(tool); + continue; + } + callable.add(tool); + } + return callable; + } + + private async callToolFor( + principal: ApiKeyPrincipal, + name: string, + input: unknown, + idempotencyKey?: string, + ): Promise { + const startedAt = Date.now(); + const binding = await this.deps.bindings.get(principal.keyId); + + // No binding ⇒ nothing is callable, and there is no agent to attribute the + // attempt to. Audited with the key id so a probing key is still visible. + if (!binding) { + this.record(principal, 'unbound', name, false, 'no public MCP binding', startedAt, false); + throw new McpError(ErrorCode.InvalidParams, unavailableToolMessage(name)); + } + + // The dispatcher is resolved BEFORE the authorization checks now, because + // `isEffectiveWrite` consults its declaration-driven `isWriteCapable`. That + // reordering is safe — resolving a dispatcher runs no tool and reveals + // nothing to the caller — and it is necessary: deciding "is this a write" + // from the binding alone would miss an annotated write tool the operator + // filed under `read_tools`. + const dispatcher = this.deps.resolveDispatcher(binding.agentId); + if (!dispatcher) { + this.record(principal, binding.agentId, name, false, 'agent not active', startedAt, false); + throw new McpError(ErrorCode.InternalError, unavailableToolMessage(name)); + } + + const isWrite = this.isEffectiveWrite(dispatcher, binding, name); + const callable = this.callableToolNames(principal, binding, dispatcher); + if (!callable.has(name)) { + this.record(principal, binding.agentId, name, false, 'not allowlisted', startedAt, isWrite); + throw new McpError(ErrorCode.InvalidParams, unavailableToolMessage(name)); + } + + // Stricter budget for writes, on its own limiter instance. Checked AFTER + // authorization so a caller cannot map the allowlist by watching which + // names cost quota, and BEFORE the concurrency slot so an over-budget + // caller cannot occupy one. + if ( + isWrite && + !this.deps.writeRateLimiter.tryConsume(principal.keyId, binding.writeRateLimitPerMinute) + ) { + this.record(principal, binding.agentId, name, false, 'write rate limited', startedAt, true); + throw new McpError( + ErrorCode.InvalidRequest, + `write rate limit exceeded: this key is limited to ${String(binding.writeRateLimitPerMinute)} write calls/minute`, + ); + } + + // The privacy data-plane handle for THIS dispatch. Per-request scope ids: + // the Privacy Shield keys its Dataset Store on `(sessionId, turnId)`, so + // sharing them across callers would let one caller's digest resolve against + // another caller's rows. + const scope = { sessionId: `public-mcp:${principal.keyId}`, turnId: randomUUID() }; + const base = this.deps.privacy?.(scope); + if (!base) { + // No `privacyRedact` provider installed. `ToolDispatchService` would pass + // the raw result straight through — parity with the chat path, and a data + // leak here. Refusing at CALL time rather than at boot keeps `tools/list` + // honest so an integrator can still discover the contract. + if (this.privacyMaskingRequired) { + this.record( + principal, + binding.agentId, + name, + false, + 'privacy provider absent', + startedAt, + isWrite, + ); + throw new McpError( + ErrorCode.InternalError, + 'public MCP tool calls are disabled: no privacy provider is installed, so a response could carry unmasked personal data', + ); + } + } + const gate = base ? createFailClosedPrivacyGate(base) : undefined; + + if (this.inFlight >= this.maxConcurrentCalls) { + this.record(principal, binding.agentId, name, false, 'concurrency ceiling', startedAt, isWrite); + throw new McpError( + ErrorCode.InternalError, + 'public MCP endpoint is at capacity — retry shortly', + ); + } + + // The seam the sibling unit built, consumed. A CARRIER only — it performs no + // authorization, which is why every gate above already ran. `principal` is + // the API-key id so an audit consumer beneath dispatch can attribute the + // call; `scopes` is passed for downstream policy, not because anything under + // dispatch enforces it. + const options: ToolDispatchOptions = { + caller: { + principal: principal.keyId, + scopes: principal.scopes, + requestId: scope.turnId, + }, + // Applied by the dispatch layer to write-capable tools ONLY, and only + // when an idempotency store is wired. Advisory — see the README. + ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), + }; + + this.inFlight += 1; + try { + const result = await this.withTimeout(name, () => + this.dispatchWithPrivacy(dispatcher, name, input, options, gate), + ); + + // FAIL CLOSED. The gate turned a masking exception into a placeholder + // rather than letting the dispatcher's fail-open branch return the raw + // rows, so the result in hand may be that placeholder — or, worse if this + // check were missing, a partially-masked body. Discard it entirely. + if (gate?.maskingFailed() === true) { + this.record( + principal, + binding.agentId, + name, + false, + 'privacy masking failed', + startedAt, + isWrite, + ); + throw new McpError( + ErrorCode.InternalError, + 'privacy masking failed for this tool result — the result was discarded rather than returned unmasked', + ); + } + + // FAIL CLOSED, second half: the boundary must have been CROSSED, not + // merely "not failed". + // + // `maskingFailed()` cannot tell "masking succeeded" from "masking never + // ran", and "never ran" is the shape every leak in this family has taken: + // a dispatch branch that skipped `afterDispatch` entirely (the throwing + // -handler bug), a handler returning a non-string that the masker declines + // to walk, an intern-exempt name that slipped past the allowlist. In each + // case the raw bytes are already in hand and every check above is happy. + // + // So gate on the positive signal instead. `masked()` is true only when + // `internToolResultV4` actually returned a digest for this dispatch, which + // is the one thing that cannot be true by accident. + // + // ONE exception: `origin === 'dispatcher'` marks content the dispatch + // layer authored itself — "unknown tool", "plugin not ready" — which + // names only the tool the caller asked for and the owning plugin id, and + // carries no tool data for masking to have crossed. Anything else, + // INCLUDING an `origin`-less result from a dispatcher that predates the + // field, must have been masked. + if (gate !== undefined && result.origin !== 'dispatcher' && !gate.masked()) { + this.record( + principal, + binding.agentId, + name, + false, + 'privacy masking skipped', + startedAt, + isWrite, + ); + throw new McpError( + ErrorCode.InternalError, + 'privacy masking did not run for this tool result — the result was discarded rather than returned unmasked', + ); + } + + this.record( + principal, + binding.agentId, + name, + result.isError !== true, + result.isError === true ? 'tool reported an error' : null, + startedAt, + isWrite, + ); + return result; + } catch (error) { + // An McpError raised above is already audited; re-auditing would double + // -count it. Only genuinely unexpected throws land a second row. + if (!(error instanceof McpError)) { + this.record(principal, binding.agentId, name, false, String(error), startedAt, isWrite); + } + throw error; + } finally { + this.inFlight -= 1; + } + } + + /** + * Dispatches with the fail-closed privacy gate installed. + * + * The gate is handed over as `ToolDispatchService`'s `privacy` dependency — + * which is a per-SERVICE dep, not a per-call one. Since the gate must be + * per-call (its `maskingFailed()` is per-call state), the dispatcher supplied + * by the wiring reads the handle through a mutable slot this method fills for + * the duration of one dispatch. `PublicMcpDispatcher` therefore carries an + * optional `withPrivacy` escape hatch; when the wiring does not provide one, + * the dispatcher was built with a handle already bound and this is a plain + * call. + */ + private async dispatchWithPrivacy( + dispatcher: PublicMcpDispatcher, + name: string, + input: unknown, + options: ToolDispatchOptions, + gate: PublicMcpPrivacyGate | undefined, + ): Promise { + if (gate && dispatcher.withPrivacy) { + return dispatcher.withPrivacy(gate.handle, () => dispatcher.dispatch(name, input, options)); + } + return dispatcher.dispatch(name, input, options); + } + + /** + * Bounds one dispatch. + * + * The timer is cleared on BOTH paths. A dangling timer would keep the + * process's event loop alive per call, and — worse for a public endpoint — + * the losing side of the race is the only thing that ever clears it, so a + * fast tool would leak one timer per successful call. + * + * Note the dispatch itself is not cancelled (nothing in `ToolDispatchService` + * accepts an AbortSignal); the SLOT is released, which is what the + * concurrency ceiling needs. Recorded here so nobody reads this as + * cancellation. + */ + private async withTimeout( + toolName: string, + run: () => Promise, + ): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + run(), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new McpError( + ErrorCode.InternalError, + `tool \`${toolName}\` exceeded the ${String(this.toolTimeoutMs)}ms public MCP timeout`, + ), + ), + this.toolTimeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + + /** One audit row per call ATTEMPT, including every refusal. A refusal that + * leaves no trace is the one an operator cannot investigate. */ + private record( + principal: ApiKeyPrincipal, + agentId: string, + toolName: string, + ok: boolean, + error: string | null, + startedAt: number, + write: boolean, + ): void { + if (!this.deps.audit) return; + try { + this.deps.audit({ + keyId: principal.keyId, + agentId, + toolName, + ok, + error, + durationMs: Math.max(0, Date.now() - startedAt), + calledAt: new Date(startedAt), + // Same vocabulary the base branch established: a resolved identity, or + // the literal `unresolved` when there is none to name. + actingIdentity: principal.keyId ? `apikey:${principal.keyId}` : 'unresolved', + write, + }); + } catch (err) { + // An audit sink that throws must not turn a successful call into a + // failure — nor a refusal into a success. + console.warn(`[public-mcp] audit sink threw: ${String(err)}`); + } + } +} diff --git a/middleware/src/mcp/wirePublicMcp.ts b/middleware/src/mcp/wirePublicMcp.ts new file mode 100644 index 00000000..186b9dd4 --- /dev/null +++ b/middleware/src/mcp/wirePublicMcp.ts @@ -0,0 +1,364 @@ +/** + * W2-3 (issue #542) — assembles and mounts the public MCP endpoint. + * + * A separate wire module, following the existing `wire*.ts` convention, for two + * reasons: `index.ts` is already ~4500 lines, and — more usefully — the whole + * assembly becomes testable end-to-end against the real router chain instead of + * a bare `express()` app. Mounting a bare app is exactly the epic #470 bug the + * doc comment at the top of `auth/publicPaths.ts` records: the runner router's + * e2e test built its own app, so the test passed while production 401'd behind + * the blanket `/api` gate. + */ + +import type { Express, RequestHandler } from 'express'; +import type { Pool } from 'pg'; + +import type { ApiKeySecretStorage, ApiKeyStore, AuditLog, RateLimiter } from '@omadia/api-key-auth'; +import { createApiKeyStore, createRateLimiter } from '@omadia/api-key-auth'; +import { + AgentGraphStore, + createPrivacyTurnHandle, + ToolDispatchService, + ToolIdempotencyStore, +} from '@omadia/orchestrator'; +import type { + NativeToolRegistry, + OrchestratorRegistry, + PrivacyTurnHandle, +} from '@omadia/orchestrator'; +import type { PrivacyGuardService } from '@omadia/plugin-api'; + +import type { SecretVault } from '../secrets/vault.js'; +import { + createPublicMcpKeyBindingStore, + type PublicMcpKeyBindingStore, +} from './publicMcpKeyBindings.js'; +import { PUBLIC_MCP_PATH, PUBLIC_MCP_SERVER_NAME } from './publicMcpPath.js'; +import { createPublicMcpRouter } from './publicMcpRouter.js'; +import type { PublicMcpAuditEntry, PublicMcpDispatcher } from './publicMcpServer.js'; + +/** + * The vault namespace holding API-key records. + * + * Deliberately the SAME namespace `@omadia/channel-api` writes to (a plugin's + * `ctx.secrets` is its own manifest id), so there is ONE key list and ONE place + * to revoke. A key's SCOPES decide what it can reach: `chat:write` gets the + * chat ingress, `mcp:list`/`mcp:invoke`/`mcp:write:` get this endpoint, + * and a key holding only the former reaches nothing here. A second key store + * would have meant a second revoke an operator can forget. + */ +export const API_KEY_VAULT_NAMESPACE = '@omadia/channel-api'; + +/** + * Adapts the kernel's namespaced vault to the flat `ApiKeySecretStorage` shape. + * + * Read/list ONLY — no `set`, no `delete`. `createApiKeyStore` requires a + * write-capable accessor and throws without one, which is the point: minting + * and revoking keys stays with `@omadia/channel-api`'s operator-session-gated + * `/admin/keys` routes. This module builds a store for VERIFICATION, and giving + * an internet-facing route the ability to write its own credentials is not a + * capability it has any use for. + * + * @see createVerifyOnlyApiKeyStore for how the read-only store is obtained. + */ +function readOnlyVaultStorage(vault: SecretVault): ApiKeySecretStorage { + return { + get: (key) => vault.get(API_KEY_VAULT_NAMESPACE, key), + keys: () => vault.listKeys(API_KEY_VAULT_NAMESPACE), + }; +} + +/** + * An `ApiKeyStore` usable for `verify()` only. + * + * `createApiKeyStore` demands a writer up front, so it cannot be handed the + * read-only accessor above. Rather than widen the endpoint's vault access to + * satisfy a constructor, the two write methods are stubbed to throw: reaching + * either is a programmer error (nothing on this path calls them), and a throw + * is a louder, more debuggable failure than a silent no-op that appears to have + * minted a key. + */ +export function createVerifyOnlyApiKeyStore(vault: SecretVault): ApiKeyStore { + const storage = readOnlyVaultStorage(vault); + return createApiKeyStore({ + ...storage, + set: () => { + throw new Error( + 'public MCP endpoint must not mint API keys — use @omadia/channel-api /admin/keys', + ); + }, + delete: () => { + throw new Error( + 'public MCP endpoint must not revoke API keys — use @omadia/channel-api /admin/keys', + ); + }, + }); +} + +/** The vault is only needed when the caller did not supply an `ApiKeyStore`; + * throwing here keeps that a wiring error rather than a runtime 500. */ +function requireVault(deps: WirePublicMcpDeps): SecretVault { + if (!deps.vault) { + throw new Error('mountPublicMcp requires a vault (or an explicit apiKeys store)'); + } + return deps.vault; +} + +export interface WirePublicMcpDeps { + readonly enabled: boolean; + /** See `PUBLIC_MCP_ALLOW_WITHOUT_PRIVACY_MASKING`. */ + readonly allowWithoutPrivacyMasking: boolean; + /** Resolves the installed `privacyRedact` provider. Read LIVE — installing or + * uninstalling the privacy-guard plugin must take effect without a restart, + * and in the closing direction it must take effect IMMEDIATELY. */ + readonly getPrivacyService?: () => PrivacyGuardService | undefined; + /** Only read when `apiKeys` is not supplied. */ + readonly vault?: SecretVault; + /** Bindings and the audit trail both live in the graph DB. Absent ⇒ the + * endpoint is NOT mounted: without bindings every key reaches nothing, and + * without the audit trail a public write would be unattributable. */ + readonly graphPool: Pool | undefined; + /** Resolved LIVE, not captured: the orchestrator plugin republishes the + * registry on reactivation, so a boot-time value would pin a stale set. + * Only read when `resolveDispatcher` is not supplied. */ + readonly getRegistry?: () => OrchestratorRegistry | undefined; + /** The process-wide native tool registry. Shared across agents by design — + * which is precisely why per-agent reach is decided by the binding row and + * the agent's OWN `listDomainTools()`, not by this registry. Only read when + * `resolveDispatcher` is not supplied. */ + readonly nativeToolRegistry?: NativeToolRegistry; + readonly log?: (msg: string) => void; + + // ── Test seams. Production supplies none of these. ──────────────────────── + // Each one substitutes an INFRASTRUCTURE dependency (a pool, a vault, the + // orchestrator registry), never a GATE: the allowlist check, the scope + // checks, the rate limits and the audit calls all run exactly as they do in + // production, which is what lets the e2e tests assert on real refusals. + readonly bindings?: PublicMcpKeyBindingStore; + readonly apiKeys?: ApiKeyStore; + readonly rateLimiter?: RateLimiter; + readonly keyAuditLog?: AuditLog; + readonly resolveDispatcher?: (agentId: string) => PublicMcpDispatcher | undefined; + readonly idempotency?: ToolIdempotencyStore; + readonly privacy?: (scope: { + sessionId: string; + turnId: string; + }) => PrivacyTurnHandle | undefined; + readonly audit?: (entry: PublicMcpAuditEntry) => void; + readonly toolTimeoutMs?: number; + readonly maxConcurrentCalls?: number; + /** Second limiter instance. Defaults to a fresh one — never the read one. */ + readonly writeRateLimiter?: RateLimiter; +} + +/** + * Builds the per-agent dispatcher. + * + * Mirrors the `ToolDispatchService` construction in `buildOrchestrator.ts`'s + * claude-cli branch, with ONE difference that carries all the isolation: + * `domainToolsProvider` reads THIS agent's orchestrator. Native tools come from + * the process-wide registry (there is no per-agent native registry in omadia), + * so a shared native tool is reachable by any agent — and is kept out of reach + * of a given KEY by the binding allowlist, which is checked before dispatch is + * ever consulted. + * + * Returns `undefined` for an unknown or inactive slug, which fails the call + * closed rather than falling back to the default agent. + */ +function makeDispatcherResolver( + deps: WirePublicMcpDeps, +): (agentId: string) => PublicMcpDispatcher | undefined { + if (deps.resolveDispatcher) return deps.resolveDispatcher; + const { getRegistry, nativeToolRegistry } = deps; + if (!getRegistry || !nativeToolRegistry) { + throw new Error( + 'mountPublicMcp requires getRegistry + nativeToolRegistry (or an explicit resolveDispatcher)', + ); + } + // ONE store per process, shared across agents. Keys are namespaced by tool + // name inside the store (`idempotencyCacheKey`), so cross-agent collision + // needs the same key AND the same tool name — at which point deduping is the + // correct answer anyway. Process-LOCAL: see the README's idempotency section + // for what an external consumer may rely on (less than they would assume). + const idempotency = deps.idempotency ?? new ToolIdempotencyStore(); + + return (agentId) => { + const entry = getRegistry()?.get(agentId); + if (!entry) return undefined; + + // The per-call privacy slot. `ToolDispatchService` takes `privacy` as a + // per-SERVICE dep, but the endpoint's fail-closed gate must be per-CALL + // (it carries `maskingFailed()` state). A dispatcher instance is already + // built per resolve — i.e. per request — so the slot is never shared + // between two concurrent callers; `withPrivacy` still nulls it out in a + // `finally` so a leaked handle cannot outlive its dispatch. + let slot: PrivacyTurnHandle | undefined; + + const dispatch = new ToolDispatchService({ + nativeTools: nativeToolRegistry, + domainToolsProvider: () => entry.built.orchestrator.listDomainTools(), + // Explicit, NOT the ambient `turnContext` fallback: this endpoint runs + // outside any turn, so the ambient handle is always `undefined` here and + // relying on it would ship an unmasked public endpoint. + privacy: () => slot, + idempotency, + // Raw capture receives the result BEFORE masking. Deliberately records + // SIZE ONLY — never content. `mcp_call_log` stores no tool arguments or + // results by design (0009), and a public endpoint is the last place to + // invent a new sink holding pre-masking PII. + captureRawToolResult: (name, result, caller) => { + console.log( + `[public-mcp] raw tool result captured: tool=${name} bytes=${String( + Buffer.byteLength(result, 'utf8'), + )} principal=${caller?.principal ?? 'unknown'}`, + ); + }, + }); + + return { + dispatch: (name, input, options) => dispatch.dispatch(name, input, options), + listDispatchableToolSpecs: () => dispatch.listDispatchableToolSpecs(), + isWriteCapable: (name) => dispatch.isWriteCapable(name), + async withPrivacy(handle, fn) { + slot = handle; + try { + return await fn(); + } finally { + slot = undefined; + } + }, + }; + }; +} + +/** + * Builds the per-dispatch privacy handle from the installed `privacyRedact` + * provider, or `undefined` when none is installed. + * + * `resolveBypass` is deliberately NOT passed. The operator's per-plugin + * `_privacy_mode: bypass` is a decision about their own agent's chat behaviour; + * extending it to an anonymous HTTP caller is not something anyone consented to. + * Omitting it here means the handle's own `checkBypass` returns `undefined`, and + * `createFailClosedPrivacyGate` pins it off a second time — belt and braces, + * because this is the difference between a masked digest and raw customer rows. + */ +function makePrivacyProvider( + getPrivacyService: () => PrivacyGuardService | undefined, +): (scope: { sessionId: string; turnId: string }) => PrivacyTurnHandle | undefined { + return (scope) => { + const service = getPrivacyService(); + if (!service) return undefined; + return createPrivacyTurnHandle({ + service, + sessionId: scope.sessionId, + turnId: scope.turnId, + }); + }; +} + +/** + * Maps a public MCP call onto an `mcp_call_log` row. + * + * `serverId` is NULL and `serverName` is the `omadia-public-mcp` literal: + * omadia IS the server here, so there is no `mcp_servers` row to point at (0009 + * made the FK nullable for exactly this "no server row" case). `callerKind` is + * the `api_key` member migration 0033 added, and `actingIdentity` reuses 0031's + * vocabulary — `apikey:`, or the literal `unresolved`. + * + * Fire-and-forget: an audit write must never fail a caller's request. It must + * also never be the reason one succeeds, which is why the endpoint is not + * mounted at all without a pool. + */ +export function createPublicMcpAuditSink( + graph: AgentGraphStore, + log: (msg: string) => void, +): (entry: PublicMcpAuditEntry) => void { + return (entry) => { + void graph + .insertMcpCallLog({ + serverId: null, + serverName: PUBLIC_MCP_SERVER_NAME, + toolName: entry.toolName, + callerKind: 'api_key', + callerAgent: entry.agentId, + turnId: null, + ok: entry.ok, + error: entry.error, + durationMs: entry.durationMs, + calledAt: entry.calledAt, + actingIdentity: entry.actingIdentity, + }) + .catch((err: unknown) => { + log(`[public-mcp] audit write failed: ${String(err)}`); + }); + }; +} + +/** + * Mounts the endpoint, or explains why it stayed dark. + * + * `requireAuth` runs FIRST and is load-bearing in both directions: it short- + * circuits to `next()` because `PUBLIC_MCP_PATH` is in `publicPaths.ts`, and if + * that entry were ever removed this route would 401 before `requireApiKey` ran. + * That is the intended failure mode — a path that loses its exemption goes dark + * rather than open — and it is what makes the `publicPaths` test meaningful + * rather than decorative. + */ +export function mountPublicMcp(app: Express, requireAuth: RequestHandler, deps: WirePublicMcpDeps): boolean { + const log = deps.log ?? ((msg: string) => console.log(msg)); + + if (!deps.enabled) { + log('[public-mcp] DISABLED (PUBLIC_MCP_ENABLED=false) — no router mounted'); + return false; + } + if (!deps.graphPool && !deps.bindings) { + log('[public-mcp] NOT mounted — no DATABASE_URL, so there are no key bindings and no audit trail'); + return false; + } + + const bindings = + deps.bindings ?? createPublicMcpKeyBindingStore(deps.graphPool as Pool); + const audit = + deps.audit ?? + (deps.graphPool + ? createPublicMcpAuditSink(new AgentGraphStore(deps.graphPool), log) + : undefined); + const apiKeys = deps.apiKeys ?? createVerifyOnlyApiKeyStore(requireVault(deps)); + + // Scoped to the ONE path, not `app.use(requireAuth, …)`. An unscoped mount + // would run `requireAuth` for every request on the whole app — including the + // non-`/api` surfaces (`/health`, static assets) that were never behind it. + app.use( + PUBLIC_MCP_PATH, + requireAuth, + createPublicMcpRouter({ + apiKeys, + rateLimiter: deps.rateLimiter ?? createRateLimiter(), + ...(deps.keyAuditLog ? { keyAuditLog: deps.keyAuditLog } : {}), + bindings, + // A SECOND limiter instance, not the one above. Writes get their own + // budget so a read-heavy integration's unused read headroom cannot fund a + // write burst. + writeRateLimiter: deps.writeRateLimiter ?? createRateLimiter(), + resolveDispatcher: makeDispatcherResolver(deps), + ...(audit ? { audit } : {}), + requirePrivacyMasking: !deps.allowWithoutPrivacyMasking, + privacy: + deps.privacy ?? makePrivacyProvider(deps.getPrivacyService ?? (() => undefined)), + serverName: PUBLIC_MCP_SERVER_NAME, + ...(deps.toolTimeoutMs !== undefined ? { toolTimeoutMs: deps.toolTimeoutMs } : {}), + ...(deps.maxConcurrentCalls !== undefined + ? { maxConcurrentCalls: deps.maxConcurrentCalls } + : {}), + }), + ); + + log( + `[public-mcp] mounted at POST ${PUBLIC_MCP_PATH} (API-key auth, per-key tool allowlist)${ + deps.allowWithoutPrivacyMasking + ? ' ⚠ tool calls ENABLED WITHOUT required privacy masking — responses may carry unmasked PII' + : ' — tool calls REFUSED unless privacy masking is available, and DISCARDED if masking fails' + }`, + ); + return true; +} diff --git a/middleware/src/platform/pluginContext.ts b/middleware/src/platform/pluginContext.ts index a901afca..10ab226a 100644 --- a/middleware/src/platform/pluginContext.ts +++ b/middleware/src/platform/pluginContext.ts @@ -450,6 +450,13 @@ export function createPluginContext( ...(options?.attachmentSink ? { attachmentSink: options.attachmentSink } : {}), + // #542 — carry the plugin's declared write capabilities into the registry + // so `ToolDispatchService` can give this tool duplicate-write protection. + // Without this forward, only kernel-internal registrations could declare + // themselves and no real plugin (Odoo, M365) would ever be protected. + ...(options?.writeCapabilities !== undefined + ? { writeCapabilities: options.writeCapabilities } + : {}), }); }, registerHandler(name, handler, options) { @@ -470,6 +477,11 @@ export function createPluginContext( ...(options?.attachmentSink ? { attachmentSink: options.attachmentSink } : {}), + // #542 — same forward as `register()` above; a handler-only tool is + // dispatchable by name, so it needs the same protection. + ...(options?.writeCapabilities !== undefined + ? { writeCapabilities: options.writeCapabilities } + : {}), }); }, async invoke(name, input) { @@ -903,6 +915,10 @@ export function createPluginMcpAccessor( turnDate: current?.turnDate ?? new Date().toISOString().slice(0, 10), ...(current?.agentSlug ? { agentSlug: current.agentSlug } : {}), ...(current?.privacyHandle ? { privacyHandle: current.privacyHandle } : {}), + // W3-A — the turn's MCP OAuth identity. Dropping it here made every + // plugin-attributed call resolve as an unknown caller: `unresolved` in + // the audit trail, no token, and a `per_user` server failing closed. + ...(current?.mcpUserKey ? { mcpUserKey: current.mcpUserKey } : {}), mcpCallerKind: 'plugin', mcpCallerId: pluginId, }, diff --git a/middleware/src/plugins/routines/routineRunner.ts b/middleware/src/plugins/routines/routineRunner.ts index f640a622..40446b2a 100644 --- a/middleware/src/plugins/routines/routineRunner.ts +++ b/middleware/src/plugins/routines/routineRunner.ts @@ -654,11 +654,23 @@ export class RoutineRunner { readonly cardBody?: readonly unknown[]; }> { if (routine.outputTemplate === null) { - const result = await orchestrator.runTurn({ - userMessage: routine.prompt, - userId: routine.userId, - sessionScope: `routine:${routine.id}`, - }); + // The scope is deliberate, and it must match the templated branch below. + // `orchestrator.runTurn` reads `turnContext.current()` as its parent and + // inherits `mcpUserKey` from it. Without a fresh scope here, a routine + // fired while an ambient turn is open would run under the INVOKING user's + // MCP identity rather than its own owner's (`routine.userId`) — and the + // templated branch, which does open one, would behave differently for the + // same routine. Same routine, same owner, two identities, decided by + // whether an output template happens to be configured. + const result = await turnContext.run( + { turnId: '', turnDate: today() }, + () => + orchestrator.runTurn({ + userMessage: routine.prompt, + userId: routine.userId, + sessionScope: `routine:${routine.id}`, + }), + ); return { result }; } diff --git a/middleware/src/routes/agentBuilder.ts b/middleware/src/routes/agentBuilder.ts index bec4a697..ea04e255 100644 --- a/middleware/src/routes/agentBuilder.ts +++ b/middleware/src/routes/agentBuilder.ts @@ -30,6 +30,7 @@ import { type ToolGrantRow, } from '@omadia/orchestrator'; import { + isDeprecatedMcpTransport, McpManager, mcpToolNameFromRef, turnContext, @@ -44,7 +45,14 @@ import { substituteMcpConfig, deriveMcpConfigSchema, } from '../agents/subAgentToolHydration.js'; +import { sessionIdentity } from '../auth/sessionIdentity.js'; +import { + auditIdentity, + parseDelegation, + resolveMcpUserKey, +} from '../services/mcpDelegation.js'; import { rescanAllMcpServers } from '../services/mcpRescan.js'; +import { redactSecrets } from '../services/secretRedaction.js'; import { MCP_SEVERITIES_NEEDING_ACK, refreshMcpGrantPolicy, @@ -72,6 +80,11 @@ import { type LlmVerdictStore, type LlmVerifier, } from '../services/skillVerdictLlmVerifier.js'; +import { + createPublicMcpBindingsRouter, + type OperatorSessionCheck, +} from './publicMcpBindingsRouter.js'; +import type { PublicMcpKeyBindingAdminStore } from '../mcp/publicMcpKeyBindingsAdmin.js'; export interface AgentBuilderRouterOptions { readonly getConfigStore: () => ConfigStore | undefined; @@ -113,9 +126,15 @@ export interface AgentBuilderRouterOptions { issuer: string | null; issuerHost: string | null; brokered: boolean; + /** W2-4 — which link of the client-acquisition chain applies. */ + acquisitionMode: 'stored' | 'cimd' | 'dcr' | 'manual'; + cimdSupported: boolean; + cimdBlockedReason: string | null; }>; beginAuthorization(server: McpServerRow, userKey: string): Promise<{ authorizeUrl: string }>; - completeAuthorization(state: string, code: string): Promise<{ serverId: string }>; + /** `iss` is the RFC 9207 authorization-response parameter (W0-1, D1), + * validated against the flow-bound issuer before any exchange. */ + completeAuthorization(state: string, code: string, iss?: string | null): Promise<{ serverId: string }>; setManualClient(issuer: string, clientId: string, clientSecret: string | null): Promise; getValidAccessToken(server: McpServerRow, userKey: string): Promise; }; @@ -137,6 +156,15 @@ export interface AgentBuilderRouterOptions { setToken(registryId: string, value: string): Promise; deleteToken(registryId: string): Promise; }; + /** W5-1 — the WRITE half of `public_mcp_key_bindings`, for the MCP Control + * Center's Bindings tab. The public MCP endpoint gets the READ store and + * only the read store (`wirePublicMcp.ts`); this one never reaches it. + * Absent (no graph pool) ⇒ the sub-routes 503. */ + readonly getPublicMcpBindingStore?: () => PublicMcpKeyBindingAdminStore | undefined; + /** W5-1 — operator-session check for the binding routes. Absent ⇒ they refuse + * to serve at all, rather than relying on the `requireAuth` that happens to + * sit in front of this router's mount. */ + readonly operatorAuth?: OperatorSessionCheck; } interface Live { @@ -204,6 +232,19 @@ export function createAgentBuilderRouter( options: AgentBuilderRouterOptions, ): Router { const router = Router(); + + // W5-1 — public MCP key bindings. Its own router because its auth gate must + // travel with it rather than depend on this mount sitting behind + // `requireAuth`; see `publicMcpBindingsRouter.ts`. Mounted first so the + // prefix cannot be shadowed by a later `/:slug`-shaped route. + router.use( + '/public-mcp-bindings', + createPublicMcpBindingsRouter({ + getStore: () => options.getPublicMcpBindingStore?.(), + ...(options.operatorAuth ? { operatorAuth: options.operatorAuth } : {}), + }), + ); + const mcp = new McpManager({ ...(options.mcpCallObserver ? { onToolCall: options.mcpCallObserver } : {}), ...(options.mcpCallGuard ? { guard: options.mcpCallGuard } : {}), @@ -221,13 +262,28 @@ export function createAgentBuilderRouter( : undefined; if (!server) return null; // Per-user token (bugfix, mirrors the runtime McpManager in index.ts): - // tokens are STORED under the request's session-derived key - // (oauthUserKey below) at connect time, so lookup must use the same - // key. The static `options.mcpOAuthUserKey` fallback only applies - // outside any turn context (no session identity available). - const userKey = turnContext.current()?.mcpUserKey ?? options.mcpOAuthUserKey ?? 'operator'; + // tokens are STORED under the request's session-derived key at connect + // time, so lookup must use the same key. + // + // W0-1 (D2): the previous `?? 'operator'` tail is gone. A `per_user` + // server with no resolvable identity now yields no token and the call + // fails closed, instead of quietly using the operator's authority. + const userKey = resolveMcpUserKey( + server, + turnContext.current()?.mcpUserKey, + options.mcpOAuthUserKey, + ); + if (userKey === null) return null; return options.mcpOAuth.getValidAccessToken(server, userKey); }, + resolveIdentity: async (cfg: McpServerConfig): Promise => { + const graph = options.getGraphStore(); + const server = graph + ? (await graph.listMcpServers()).find((s) => s.id === cfg.id) + : undefined; + if (!server) return null; + return auditIdentity(server, turnContext.current()?.mcpUserKey, options.mcpOAuthUserKey); + }, // Discover/test-call surface needs-auth via the route's describeAuth path, // so the manager itself doesn't need to synthesize a prompt here. onAuthFailure: async (): Promise => null, @@ -1065,14 +1121,18 @@ export function createAgentBuilderRouter( if (!l) return; // Establish the per-request MCP OAuth identity (bugfix): the shared // McpManager's getToken reads turnContext.mcpUserKey to look up the token - // under the SAME key it was stored under (oauthUserKey(req) — see - // auth-status/authorize below), instead of silently falling back to the - // static 'operator' default and missing it. `enter` (not `run`) because - // this scope is naturally bounded by the request's own async chain. + // under the SAME key it was stored under (see auth-status/authorize + // below), instead of silently missing it. `enter` (not `run`) because this + // scope is naturally bounded by the request's own async chain. + // + // W0-1: this carries the session's CANDIDATE identity. Whether it may be + // replaced by a shared one is decided per server by `resolveMcpUserKey` in + // the auth provider — not by a default here. + const discoverIdentity = sessionIdentity(req); turnContext.enter({ turnId: `mcp-discover-${str(req.params.id)}`, turnDate: today(), - mcpUserKey: oauthUserKey(req), + ...(discoverIdentity ? { mcpUserKey: discoverIdentity } : {}), }); try { const servers = await l.graph.listMcpServers(); @@ -1407,10 +1467,19 @@ export function createAgentBuilderRouter( // ── generic MCP OAuth (epic #459 W9) ────────────────────────────────────── // Tokens are keyed to the authenticated operator's identity (codex W9 fold): - // one operator's token is never reused for another. Falls back to a shared - // key only when no session identity is available (single-admin/dev). - const oauthUserKey = (req: Request): string => - req.session?.sub || req.session?.email || options.mcpOAuthUserKey || 'operator'; + // one operator's token is never reused for another. + // + // W0-1 (D2): the identity the SESSION offers, with no fallback baked in. The + // old `|| 'operator'` tail is gone — whether an unresolved identity may + // borrow a shared one is now the server's `delegation` decision, applied by + // `resolveMcpUserKey`, never an implicit default here. + // + // W4-1: `sessionIdentity` now lives in `../auth/sessionIdentity.js` (behaviour + // unchanged) so the chat routes can produce the SAME key these routes consume. + /** The key to act as for THIS server, or null when a `per_user` server has + * no resolvable identity (fail closed — never silently the operator). */ + const oauthUserKeyFor = (req: Request, server: McpServerRow): string | null => + resolveMcpUserKey(server, sessionIdentity(req)); const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); @@ -1426,24 +1495,61 @@ export function createAgentBuilderRouter( return; } if (!options.mcpOAuth) { - res.json({ protected: false, connected: false, issuer: null, needsClient: false, brokered: false }); + res.json({ + protected: false, + connected: false, + issuer: null, + needsClient: false, + brokered: false, + acquisitionMode: 'manual', + cimdSupported: false, + cimdBlockedReason: null, + delegation: server.delegation, + identityResolved: sessionIdentity(req) !== null, + }); return; } const desc = await options.mcpOAuth.describeAuth(server); if (!desc.protected) { - res.json({ protected: false, connected: false, issuer: null, needsClient: false, brokered: false }); + res.json({ + protected: false, + connected: false, + issuer: null, + needsClient: false, + brokered: false, + acquisitionMode: 'manual', + cimdSupported: false, + cimdBlockedReason: null, + delegation: server.delegation, + identityResolved: sessionIdentity(req) !== null, + }); return; } - const token = await l.graph.getMcpOAuthToken(server.id, oauthUserKey(req)); + // W0-1: null under `per_user` with no session identity — report it as + // not-connected rather than probing the shared operator token. + const userKey = oauthUserKeyFor(req, server); + const token = + userKey === null ? undefined : await l.graph.getMcpOAuthToken(server.id, userKey); const client = desc.issuer ? await l.graph.getMcpOAuthClient(desc.issuer) : undefined; res.json({ protected: true, connected: token !== undefined, issuer: desc.issuer, issuerHost: desc.issuerHost, - // A brokered server (offers DCR) needs no manual client even without one - // stored — DCR self-registers at connect. Only a delegating server does. + // W0-1 (D2): whose authority calls to this server act under, and + // whether this session actually has an identity to act as. + delegation: server.delegation, + identityResolved: userKey !== null, + // A brokered server needs no manual client even without one stored — + // either a Client ID Metadata Document (W2-4) or DCR acquires one at + // connect. Only a server with neither does. brokered: desc.brokered, + // W2-4 — which acquisition mode this issuer is on, so the UI can badge + // CIMD, explain a CIMD-capable-but-unreachable install, and make clear + // the manual client remains the Entra ID / Okta path. + acquisitionMode: desc.acquisitionMode, + cimdSupported: desc.cimdSupported, + cimdBlockedReason: desc.cimdBlockedReason, needsClient: !desc.brokered && desc.issuer !== null && client === undefined, redirectUri: options.mcpOAuth.redirectUri, }); @@ -1466,8 +1572,16 @@ export function createAgentBuilderRouter( res.status(404).json({ error: 'mcp_server_not_found' }); return; } + // W0-1 (D2): authorizing under a borrowed identity is exactly the + // confused deputy — a `per_user` server with no session identity has + // nobody to store the token for, so refuse before starting the flow. + const userKey = oauthUserKeyFor(req, server); + if (userKey === null) { + res.status(403).json({ error: 'delegation_identity_unresolved' }); + return; + } try { - const { authorizeUrl } = await options.mcpOAuth.beginAuthorization(server, oauthUserKey(req)); + const { authorizeUrl } = await options.mcpOAuth.beginAuthorization(server, userKey); res.json({ authorizeUrl }); } catch (err) { // Issuer without DCR needs a one-time manual client first. @@ -1514,13 +1628,50 @@ export function createAgentBuilderRouter( const l = live(res); if (!l) return; try { - await l.graph.deleteMcpOAuthToken(str(req.params.id), oauthUserKey(req)); + const server = (await l.graph.listMcpServers()).find((s) => s.id === str(req.params.id)); + if (!server) { + res.status(404).json({ error: 'mcp_server_not_found' }); + return; + } + // W0-1: only ever delete the token this caller actually owns. With the + // old shared fallback an identity-less session could disconnect the + // operator's token. + const userKey = oauthUserKeyFor(req, server); + if (userKey === null) { + res.status(403).json({ error: 'delegation_identity_unresolved' }); + return; + } + await l.graph.deleteMcpOAuthToken(server.id, userKey); res.status(204).end(); } catch (err) { fail(res, err); } }); + /** Set a server's delegation mode (W0-1, D2). `per_user` requires each + * caller to have its own identity; `service` is the explicit opt-in to one + * shared identity. Migration 0031 grandfathers already-connected servers + * into `service`, so this is how an operator moves one to `per_user`. */ + router.put('/mcp-servers/:id/delegation', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const delegation = parseDelegation(req.body?.delegation); + if (delegation === null) { + res.status(400).json({ error: 'invalid_delegation' }); + return; + } + const updated = await l.graph.setMcpServerDelegation(str(req.params.id), delegation); + if (!updated) { + res.status(404).json({ error: 'mcp_server_not_found' }); + return; + } + res.json({ id: updated.id, delegation: updated.delegation }); + } catch (err) { + fail(res, err); + } + }); + /** OAuth callback: exchange the code, store the token, show a done page. * Hit by the operator's own browser redirect (session cookie present); the * `state` param is the CSRF guard. */ @@ -1541,6 +1692,16 @@ export function createAgentBuilderRouter( const code = typeof req.query['code'] === 'string' ? req.query['code'] : ''; const state = typeof req.query['state'] === 'string' ? req.query['state'] : ''; const providerError = typeof req.query['error'] === 'string' ? req.query['error'] : ''; + // RFC 9207 issuer identifier (W0-1, D1). `state` proves the response + // belongs to a flow we started; it does NOT prove which authorization + // server minted the code. A repeated `iss` (Express gives an array) is + // itself a tampering signal — treat it as a mismatch, not a "pick one". + const rawIss = req.query['iss']; + if (rawIss !== undefined && typeof rawIss !== 'string') { + res.status(400).send(donePage(false, 'The authorization response carried a malformed issuer.')); + return; + } + const iss = typeof rawIss === 'string' ? rawIss : null; if (providerError) { res.status(400).send(donePage(false, `The provider returned: ${providerError}`)); return; @@ -1549,10 +1710,25 @@ export function createAgentBuilderRouter( res.status(400).send(donePage(false, 'Missing code or state.')); return; } - await options.mcpOAuth.completeAuthorization(state, code); + // The service validates `iss` against the flow-bound issuer BEFORE + // exchanging the code, so a rejected callback stores nothing. + await options.mcpOAuth.completeAuthorization(state, code, iss); res.status(200).send(donePage(true, 'The server is now authorized for you.')); } catch (err) { - res.status(400).send(donePage(false, msg(err))); + // Never echo the raw error here: it can carry the code or the PKCE + // verifier (D5). The issuer-mismatch case gets an explicit message. + if (err instanceof Error && err.name === 'McpOAuthIssuerMismatchError') { + res + .status(400) + .send( + donePage( + false, + 'The authorization response came from an unexpected issuer and was rejected. Nothing was stored. Please start the connection again.', + ), + ); + return; + } + res.status(400).send(donePage(false, redactSecrets(msg(err)))); } }); @@ -2453,15 +2629,15 @@ async function withToolVerdicts( l.graph.listMcpToolVerdicts(CURRENT_VERIFIER_VERSION), l.graph.listMcpToolVerdictAcks(CURRENT_VERIFIER_VERSION), ]); - const vmap = new Map(verdicts.map((v) => [`${v.serverId}${v.toolName}`, v])); - const amap = new Map(acks.map((a) => [`${a.serverId}${a.toolName}`, a])); + const vmap = new Map(verdicts.map((v) => [`${v.serverId}\0${v.toolName}`, v])); + const amap = new Map(acks.map((a) => [`${a.serverId}\0${a.toolName}`, a])); return servers.map((s) => ({ ...s, discoveredTools: (s.discoveredTools as ReadonlyArray>).map( (tool) => { const name = typeof tool['name'] === 'string' ? (tool['name'] as string) : ''; - const v = vmap.get(`${s.id}${name}`); - const a = amap.get(`${s.id}${name}`); + const v = vmap.get(`${s.id}\0${name}`); + const a = amap.get(`${s.id}\0${name}`); const ackValid = v !== undefined && a !== undefined && a.contentHash === v.contentHash; const verdict: McpToolVerdictField = v ? { @@ -2478,11 +2654,21 @@ async function withToolVerdicts( })); } -function mcpNode(s: McpServerRow) { +/** + * Row → API node for an MCP server. Exported for unit tests (issue #541). + * + * `transportDeprecated` is derived from `DEPRECATED_MCP_TRANSPORTS`, never + * hard-coded: the web-ui uses it to badge legacy rows without duplicating the + * spec's deprecation list. Purely additive — the row's transport is returned + * unchanged and no DB constraint moved. + */ +export function mcpNode(s: McpServerRow) { return { id: s.id, name: s.name, transport: s.transport, + /** MCP 2026-07-28 deprecated this transport (see DEPRECATED_MCP_TRANSPORTS). */ + transportDeprecated: isDeprecatedMcpTransport(s.transport), endpoint: s.endpoint, status: s.status, lastDiscoveredAt: s.lastDiscoveredAt ? s.lastDiscoveredAt.toISOString() : null, diff --git a/middleware/src/routes/chat.ts b/middleware/src/routes/chat.ts index d465d85f..00b8c4ae 100644 --- a/middleware/src/routes/chat.ts +++ b/middleware/src/routes/chat.ts @@ -2,14 +2,16 @@ import { Router } from 'express'; import type { Request, Response } from 'express'; import { z } from 'zod'; import { isNoReply, logNoReplyDrop } from '@omadia/channel-sdk'; -import { MAX_STEER_LENGTH, steeringBus } from '@omadia/orchestrator'; +import { MAX_STEER_LENGTH, steeringBus, today, turnContext } from '@omadia/orchestrator'; import type { AskObserver, ChatAgent, ChatSessionStore, + TurnContextValue, } from '@omadia/orchestrator'; import type { AgentResolver } from '../agents/resolveAgentForTool.js'; +import { sessionIdentity } from '../auth/sessionIdentity.js'; const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,80}$/; const AGENT_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; @@ -92,6 +94,44 @@ function resolveUserId(req: Request): string | undefined { return USER_ID_RE.test(raw) ? raw : undefined; } +/** + * W4-1 — the missing `mcpUserKey` PRODUCER for the HTTP chat paths. + * + * Migration 0031 made MCP delegation explicit per server. A `per_user` server + * resolves its OAuth token under the CALLER's identity, which the auth provider + * in `src/index.ts` reads as `turnContext.current()?.mcpUserKey`. The MCP + * discover route set that; the chat routes never did, so every `per_user` + * server was unreachable from chat: no token was sent, the audit row recorded + * the literal `unresolved`, and the turn failed closed with + * `delegationBlockedMessage`. New servers default to `per_user`, so every + * newly-created server was broken out of the box. + * + * The scope established here is the OUTER one; the orchestrator opens its own + * turn scope and carries `mcpUserKey` over from this parent. + * + * Two deliberate non-decisions: + * + * - The value is `sessionIdentity(req)`, NOT `resolveUserId(req)`. The latter + * reads `req.session.omadia_user_id` and falls through to the CLIENT-SENT + * `x-user-id` header; keying MCP tokens on a client-controlled header would + * let any caller act as any user. `mcpUserKey` is the OAuth-shaped identity + * the token table is actually keyed on. + * - When no identity resolves, `mcpUserKey` is left UNSET. There is no + * fallback. A `per_user` server then fails closed exactly as W0-1 intends; + * inventing a substitute is the confused deputy that fix closed. + */ +function chatTurnContext(req: Request, sessionScope: string): TurnContextValue { + const identity = sessionIdentity(req); + return { + // Placeholder ids: the orchestrator overwrites both in its own inner turn + // scope and carries only `mcpUserKey` across. Kept descriptive so a stray + // log line from this scope is still attributable. + turnId: `http-chat-${sessionScope}`, + turnDate: today(), + ...(identity ? { mcpUserKey: identity } : {}), + }; +} + /** * NDJSON framing: one JSON event per line. Easier to parse than SSE, works * with a plain fetch+ReadableStream on the browser side, and survives any @@ -212,11 +252,17 @@ export function createChatRouter( try { const userId = resolveUserId(req); const sessionScope = resolveScope(parsed.data); - const result = await chat.chat({ - userMessage: parsed.data.message, - sessionScope, - ...(userId ? { userId } : {}), - }); + // W4-1: the whole turn runs inside the identity scope. `run` (not + // `enter`) because a plain async call's own async chain bounds it. + const result = await turnContext.run( + chatTurnContext(req, sessionScope), + () => + chat.chat({ + userMessage: parsed.data.message, + sessionScope, + ...(userId ? { userId } : {}), + }), + ); // Snapshot capture (Phase A) — first turn pins the session to the // resolved Agent. Subsequent turns use the pinned snapshot via // resolveAgentForRequest above; this is a no-op then. @@ -407,13 +453,23 @@ export function createChatRouter( // before the first token lands. safeWrite({ type: 'agent_bound', slug: effectiveSlug }); - const iterator = chat.chatStream( - { - userMessage: parsed.data.message, - sessionScope: resolveScope(parsed.data), - ...(userId ? { userId } : {}), - }, - observer, + // W4-1: `runGenerator`, NOT `run`/`enter`. `enterWith` binds the store to + // the async resource executing at that instant, and an async generator is + // resumed in the async context of whoever called `.next()` — so the + // identity would be gone the moment the orchestrator yielded its first + // event, which is before any tool (and therefore any MCP call) runs. See + // the warning on `turnContext.enter`. + const iterator = turnContext.runGenerator( + chatTurnContext(req, resolveScope(parsed.data)), + () => + chat.chatStream( + { + userMessage: parsed.data.message, + sessionScope: resolveScope(parsed.data), + ...(userId ? { userId } : {}), + }, + observer, + ), ); // Keep draining the generator even after the client disconnects so the // orchestrator's 'done' path fires — that's where sessionLogger.log() diff --git a/middleware/src/routes/mcpClientMetadata.ts b/middleware/src/routes/mcpClientMetadata.ts new file mode 100644 index 00000000..19be8966 --- /dev/null +++ b/middleware/src/routes/mcpClientMetadata.ts @@ -0,0 +1,72 @@ +/** + * The Client ID Metadata Document endpoint (W2-4, issue #546). + * + * `GET /.well-known/omadia-mcp-client` serves the JSON an authorization server + * dereferences when omadia hands it a CIMD `client_id`. Public by design and by + * necessity: an IdP fetches it with no credential of ours, exactly like + * `/.well-known/oauth-protected-resource` on the other side of the protocol. + * It contains no secret — only the redirect URI and a display name, both of + * which the IdP already sees during the authorize round-trip. + * + * ── Degraded path, not a hard failure ─────────────────────────────────────── + * When no inbound-reachable public base origin is configured, this route answers + * **501 Not Implemented** with an actionable message instead of serving a + * document with a wrong or unreachable `client_id`. That is deliberate: CIMD + * requires the IdP to reach IN to omadia, which is strictly stronger than the + * outbound-redirect-only requirement every other mode has, and impossible on a + * firewalled or air-gapped install. Those installs keep working through the + * manual client path — a 501 here breaks nothing. + */ + +import { Router, type Request, type Response } from 'express'; + +import { CIMD_METADATA_PATH, buildCimdDocument } from '../services/mcpCimd.js'; + +export interface McpClientMetadataOptions { + /** + * The stable metadata-document URL (which IS the `client_id`), or null when + * `FLOW_PUBLIC_BASE_URL` is unset. Derived from CONFIG, never from the inbound + * `Host` header — a per-request host would mint a different client_id per + * proxy hop and invalidate every stored `mcp_oauth_clients` row. + */ + readonly metadataUrl: string | null; + /** + * MUST be `McpOAuthService.redirectUri` verbatim. The AS matches the authorize + * request's `redirect_uri` against the `redirect_uris` served here; a mismatch + * fails every code exchange, at the provider, far from the cause. Wired from + * the same variable in index.ts and asserted in mcpOAuth.test.ts. + */ + readonly redirectUri: string | null; + readonly clientName?: string; +} + +/** Path this router serves, re-exported so callers need not import two modules. */ +export { CIMD_METADATA_PATH }; + +export function createMcpClientMetadataRouter(options: McpClientMetadataOptions): Router { + const router = Router(); + + router.get(CIMD_METADATA_PATH, (_req: Request, res: Response) => { + const { metadataUrl, redirectUri } = options; + if (!metadataUrl || !redirectUri) { + res.status(501).json({ + error: 'cimd_unavailable', + message: + 'Client ID Metadata Documents are not available on this install: no inbound-reachable public base URL is configured. Set FLOW_PUBLIC_BASE_URL to an https origin this deployment is reachable at FROM THE INTERNET (the identity provider must fetch this document). If inbound access is not possible — the normal case behind a corporate firewall — nothing is broken: register a one-time OAuth client per issuer in the MCP Control Center instead. That manual path is fully supported and is the correct path for Microsoft Entra ID and Okta, neither of which supports CIMD.', + }); + return; + } + // Cacheable but short: an IdP may fetch this on every authorize, and the + // document only changes when the operator changes the deployment's base URL. + res.setHeader('cache-control', 'public, max-age=300'); + res.json( + buildCimdDocument({ + metadataUrl, + redirectUri, + ...(options.clientName ? { clientName: options.clientName } : {}), + }), + ); + }); + + return router; +} diff --git a/middleware/src/routes/publicMcpBindingsRouter.ts b/middleware/src/routes/publicMcpBindingsRouter.ts new file mode 100644 index 00000000..dd6831f9 --- /dev/null +++ b/middleware/src/routes/publicMcpBindingsRouter.ts @@ -0,0 +1,236 @@ +/** + * W5-1 — the operator surface for `public_mcp_key_bindings`. + * + * Mounted by `agentBuilder.ts` under the existing `/api/v1/operator` parent, so + * it reuses the web-ui's `callJson` base and needs no new mount point. + * + * WHY A SEPARATE FILE RATHER THAN THREE MORE HANDLERS IN `agentBuilder.ts`. + * Every other route on that router is gated only by the `app.use('/api', + * requireAuth, …)` that sits in front of the mount — mount order is the whole + * auth story. That is fine for canvas CRUD and NOT fine here: these three routes + * decide what a third-party API key may do against an internet-facing endpoint, + * and their gate must travel WITH them rather than depend on where they happen + * to be mounted. Isolating them in their own router lets the gate be attached to + * the router itself and, just as importantly, lets it be TESTED that way — + * mounted bare on an express app with no `requireAuth` anywhere, which is the + * only arrangement in which a missing gate is actually observable. + * + * The gate below is copied from `harness-channel-api/src/adminKeysRouter.ts` + * (issue #438), not reinvented: same `operatorAuth.hasValidSession` call, same + * 503-when-unwired / 401-on-missing / 401-on-invalid triple, same `{code, + * message}` body shape `requireAuth` returns. Two admin surfaces that answer + * "who are you" differently is how one of them ends up subtly weaker. + */ + +import { Router, type NextFunction, type Request, type Response } from 'express'; + +import type { + PublicMcpKeyBindingAdminStore, + PublicMcpKeyBindingInput, +} from '../mcp/publicMcpKeyBindingsAdmin.js'; +import { validateBindingInput } from '../mcp/publicMcpKeyBindingsAdmin.js'; + +/** The subset of `OperatorAuthAccessor` this router needs. Structural rather + * than an import of the plugin-api type so the middleware's own routes do not + * take a dependency on the plugin surface just to type one method. */ +export interface OperatorSessionCheck { + hasValidSession(cookieHeader: string | undefined): Promise; +} + +export interface PublicMcpBindingsRouterOptions { + /** Absent ⇒ every route 503s. The store needs the graph pool; without it + * there is nothing to read or write. */ + readonly getStore: () => PublicMcpKeyBindingAdminStore | undefined; + /** Absent ⇒ every route 503s, BEFORE any handler runs. Never a fallback to + * "unauthenticated but mounted". */ + readonly operatorAuth?: OperatorSessionCheck; +} + +export function createPublicMcpBindingsRouter( + options: PublicMcpBindingsRouterOptions, +): Router { + const router = Router(); + + // Fail-closed operator-session gate, applied to every route below. Copied + // verbatim in behaviour from `adminKeysRouter.ts:76-110`. + router.use((req: Request, res: Response, next: NextFunction) => { + const { operatorAuth } = options; + if (!operatorAuth) { + // No operatorAuth wired (an older host, or a narrow test/migration + // context) — refuse to serve rather than silently mounting a write path + // to an authorization table with no auth check at all. + res.status(503).json({ + code: 'operator_auth.unavailable', + message: 'operator auth unavailable', + }); + return; + } + const cookieHeader = req.headers.cookie; + void operatorAuth.hasValidSession(cookieHeader).then( + (valid) => { + if (valid) { + next(); + return; + } + if (!cookieHeader) { + res.status(401).json({ code: 'auth.missing', message: 'no session' }); + return; + } + res.status(401).json({ code: 'auth.invalid', message: 'session invalid or expired' }); + }, + () => { + // `hasValidSession` is documented never to throw, but a broken + // implementation must not crash the request — treat it as invalid. + res.status(401).json({ code: 'auth.invalid', message: 'session invalid or expired' }); + }, + ); + }); + + /** + * Logs the real error and answers with a fixed string. + * + * The operator gate runs first, so nothing here reaches an anonymous caller — + * but pg errors name tables, columns and constraints, sometimes carry the + * connection host, and land verbatim in browser devtools and whatever ships + * the UI's logs. None of that helps the operator and all of it helps whoever + * reads those logs next. + */ + function fail(res: Response, code: string, err: unknown): void { + console.error('[public-mcp-bindings]', code, err); + res.status(500).json({ code, message: 'the request could not be completed' }); + } + + function storeOr503(res: Response): PublicMcpKeyBindingAdminStore | undefined { + const store = options.getStore(); + if (!store) { + res.status(503).json({ + code: 'public_mcp_bindings.unavailable', + message: 'public MCP key bindings require a graph database', + }); + return undefined; + } + return store; + } + + // ── List ──────────────────────────────────────────────────────────────── + router.get('/', async (_req: Request, res: Response) => { + const store = storeOr503(res); + if (!store) return; + try { + res.json({ bindings: await store.list() }); + } catch (err) { + fail(res, 'public_mcp_bindings.list_failed', err); + } + }); + + // ── Create / replace ──────────────────────────────────────────────────── + router.post('/', async (req: Request, res: Response) => { + const store = storeOr503(res); + if (!store) return; + + const body = (req.body ?? {}) as Record; + + // TYPE-CHECK, NEVER COERCE, on both optional fields. + // + // The previous `body[x] === undefined ? {} : Number(body[x])` guard let JSON + // `null` through — `null` is not `undefined` — and `Number(null)` is `0`, + // which is a VALID write budget. A client sending `null` to mean "use the + // default" got a key that authenticates, resolves its binding, and is + // throttled to nothing on every write while the UI shows write tools listed. + // `[]`, `false` and `""` coerce to `0` identically; `true` coerces to `1`. + // Both fields decide what an internet-facing key may do, so a value we + // cannot read at face value is a 400, not a guess. + const rawRate = body['writeRateLimitPerMinute']; + if (rawRate !== undefined && typeof rawRate !== 'number') { + res.status(400).json({ + error: 'invalid_request', + code: 'write_rate_limit_invalid_type', + message: 'writeRateLimitPerMinute must be a number, or omitted to take the default', + }); + return; + } + // Same class of silence on the other side: a present-but-non-boolean + // `enabled` used to be dropped on the floor, and under the old + // `?? true` default "dropped" meant "activate". + const rawEnabled = body['enabled']; + if (rawEnabled !== undefined && typeof rawEnabled !== 'boolean') { + res.status(400).json({ + error: 'invalid_request', + code: 'enabled_invalid_type', + message: 'enabled must be a boolean, or omitted to leave the current state untouched', + }); + return; + } + + const input: PublicMcpKeyBindingInput = { + keyId: typeof body['keyId'] === 'string' ? body['keyId'].trim() : '', + agentId: typeof body['agentId'] === 'string' ? body['agentId'].trim() : '', + readTools: Array.isArray(body['readTools']) ? (body['readTools'] as readonly string[]) : [], + writeTools: Array.isArray(body['writeTools']) + ? (body['writeTools'] as readonly string[]) + : [], + ...(rawRate === undefined ? {} : { writeRateLimitPerMinute: rawRate }), + // Absent stays absent all the way to the store — that is what keeps a + // revoked binding revoked across a save that never mentions it. + ...(rawEnabled === undefined ? {} : { enabled: rawEnabled }), + }; + + // The reader's own rules decide. See `validateBindingInput`. + const validated = validateBindingInput(input); + if (!validated.ok) { + res.status(400).json({ error: 'invalid_request', ...validated.error }); + return; + } + + try { + const { binding, created } = await store.upsert(validated.value); + // 201 only for a row that did not exist. "Created" over an existing + // binding is the operator's only per-request hint that they landed on + // somebody else's row — spending it on every save makes it worthless. + res.status(created ? 201 : 200).json({ binding }); + } catch (err) { + fail(res, 'public_mcp_bindings.upsert_failed', err); + } + }); + + // ── Revoke / restore (park and un-park, never delete) ─────────────────── + // A revoked binding keeps its configured tool lists so an operator can see + // what the integration USED to reach, and can restore it without + // reconstructing the allowlist from memory. `DELETE` exists on the store for + // completeness but is deliberately not exposed here: the destructive path + // wants a deliberate decision, and parking already stops every call. + // + // RESTORE IS ITS OWN ROUTE rather than a side effect of saving. Since an + // upsert now preserves `enabled`, re-arming a key had to become something an + // operator does ON PURPOSE — and a dedicated route makes that intent legible + // in an access log, where `POST /:keyId` would not be. + function setEnabledRoute(enabled: boolean, code: string) { + return async (req: Request, res: Response): Promise => { + const store = storeOr503(res); + if (!store) return; + + const rawKeyId = req.params['keyId']; + const keyId = Array.isArray(rawKeyId) ? rawKeyId[0] : rawKeyId; + if (!keyId) { + res.status(400).json({ error: 'invalid_request', message: 'missing key id' }); + return; + } + + try { + const binding = await store.setEnabled(keyId, enabled); + if (!binding) { + res.status(404).json({ error: 'not_found', keyId }); + return; + } + res.json({ binding }); + } catch (err) { + fail(res, code, err); + } + }; + } + + router.post('/:keyId/revoke', setEnabledRoute(false, 'public_mcp_bindings.revoke_failed')); + router.post('/:keyId/restore', setEnabledRoute(true, 'public_mcp_bindings.restore_failed')); + + return router; +} diff --git a/middleware/src/services/mcpAuthDiscovery.ts b/middleware/src/services/mcpAuthDiscovery.ts index cda324f1..f6a33097 100644 --- a/middleware/src/services/mcpAuthDiscovery.ts +++ b/middleware/src/services/mcpAuthDiscovery.ts @@ -34,6 +34,17 @@ export interface AuthServerMetadata { readonly codeChallengeMethods: readonly string[]; readonly grantTypes: readonly string[]; readonly scopesSupported: readonly string[]; + /** RFC 9207 `authorization_response_iss_parameter_supported` (W0-1, D1). When + * the AS advertises this, an authorization response WITHOUT `iss` is a + * protocol violation and must be rejected — that is what makes mix-up + * detection enforceable rather than best-effort. */ + readonly issParameterSupported: boolean; + /** `client_id_metadata_document_supported` (W2-4). True when this AS accepts a + * Client ID Metadata Document — an https `client_id` it DEREFERENCES — in + * place of a pre-registered or dynamically-registered client. Only advertised + * by MCP-native brokers; Entra ID and Okta never set it, which is precisely + * why the manual client path stays permanent. */ + readonly clientIdMetadataDocumentSupported: boolean; } export interface DiscoveredAuth { @@ -217,6 +228,12 @@ export class McpAuthDiscovery { codeChallengeMethods: strArr(doc['code_challenge_methods_supported']), grantTypes: strArr(doc['grant_types_supported']), scopesSupported: strArr(doc['scopes_supported']), + issParameterSupported: doc['authorization_response_iss_parameter_supported'] === true, + // Strict `=== true`: an AS that omits the flag, or sends a truthy-ish + // string, has NOT promised to dereference a metadata document. Guessing + // here would send a client_id the AS cannot resolve and fail the whole + // authorize round-trip instead of falling through to manual. + clientIdMetadataDocumentSupported: doc['client_id_metadata_document_supported'] === true, }; } diff --git a/middleware/src/services/mcpCimd.ts b/middleware/src/services/mcpCimd.ts new file mode 100644 index 00000000..c11ee6e3 --- /dev/null +++ b/middleware/src/services/mcpCimd.ts @@ -0,0 +1,229 @@ +/** + * Client ID Metadata Documents (CIMD) — W2-4, issue #546. + * + * CIMD is a THIRD client-acquisition mode alongside the two that already + * shipped in epic #459 W9. It is not a replacement for either: + * + * stored an OAuth client already persisted for this issuer. + * cimd the `client_id` IS an https URL. omadia serves a small JSON + * document there; the authorization server DEREFERENCES that URL to + * learn `redirect_uris` / `client_name`. Replaces RFC 7591 Dynamic + * Client Registration at MCP-native brokers (Smithery-class). + * dcr RFC 7591. Deprecated by the MCP spec on a 12-month clock, kept + * working, warned about. + * manual an operator-registered app. THE ENTRA ID / OKTA PATH — neither IdP + * supports CIMD — and permanently supported. + * + * ⚠️ The load-bearing operational fact: CIMD inverts the network direction. + * Every other mode only needs omadia to reach OUT (a redirect the browser + * follows, an outbound POST). CIMD needs the IdP to reach IN and GET a URL on + * omadia's own host. Behind a corporate firewall, on an air-gapped install, or + * on any deployment whose public origin is not actually inbound-routable, that + * is impossible. Such installs must degrade cleanly to the manual path — never + * break — which is why every entry point here returns a reason string rather + * than throwing, and why the metadata route answers 501 instead of 500. + */ + +import { assertPublicHttpsUrl } from './ssrfGuard.js'; + +/** + * Path the metadata document is served from. Exported as the single shared + * constant because three places must agree on it — the express route, the + * requireAuth allowlist (`auth/publicPaths.ts`), and the `client_id` the + * authorization server is handed. A copy-pasted literal in any one of them is + * the exact drift class `publicPaths.ts`'s module doc was written about. + */ +export const CIMD_METADATA_PATH = '/.well-known/omadia-mcp-client'; + +/** `client_name` advertised in the document and on the DCR path, so the two + * modes present omadia identically in a provider's consent screen. */ +export const CIMD_CLIENT_NAME = 'omadia MCP'; + +/** How long a reachability verdict is trusted before re-probing. */ +const REACHABILITY_TTL_MS = 5 * 60 * 1000; + +/** Cap on the self-probe response so a misconfigured reverse proxy that streams + * an HTML error page cannot be read unbounded. */ +const MAX_DOCUMENT_BYTES = 64 * 1024; + +const PROBE_TIMEOUT_MS = 5_000; + +/** The served document's shape. `token_endpoint_auth_method: 'none'` is not a + * shortcut — a CIMD client is inherently public (its metadata is world + * readable), so PKCE is the only thing protecting the exchange, and claiming a + * confidential method would be a lie the AS could act on. */ +export interface CimdClientMetadata { + readonly client_id: string; + readonly client_name: string; + readonly redirect_uris: readonly string[]; + readonly token_endpoint_auth_method: 'none'; + readonly grant_types: readonly string[]; + readonly response_types: readonly string[]; +} + +/** + * The stable metadata-document URL for a public base origin, or null when no + * base origin is configured. + * + * Stability across restarts matters: the URL IS the `client_id`, and stored + * `mcp_oauth_clients` rows reference it. Deriving it from `FLOW_PUBLIC_BASE_URL` + * (config, not request state) is what makes it stable — deriving it from an + * inbound `Host` header would mint a different client_id per proxy hop. + */ +export function cimdMetadataUrl(publicBaseUrl: string | null | undefined): string | null { + if (!publicBaseUrl) return null; + try { + // `new URL(path, base)` normalises a trailing slash on the base, so + // `https://h` and `https://h/` both yield one canonical URL. + return new URL(CIMD_METADATA_PATH, publicBaseUrl).toString(); + } catch { + return null; + } +} + +/** + * Build the document served at {@link CIMD_METADATA_PATH}. + * + * `redirectUri` MUST be the value `McpOAuthService.redirectUri` holds. If the + * two ever diverge, the AS validates the authorize request's `redirect_uri` + * against this document, finds no match, and EVERY code exchange fails — a + * failure that surfaces at the provider, far from its cause. `mcpOAuth.test.ts` + * asserts the equality directly for that reason. + */ +export function buildCimdDocument(input: { + readonly metadataUrl: string; + readonly redirectUri: string; + readonly clientName?: string; +}): CimdClientMetadata { + return { + // Self-referential by definition: the client_id a CIMD-aware AS receives is + // the URL of this very document. + client_id: input.metadataUrl, + client_name: input.clientName ?? CIMD_CLIENT_NAME, + redirect_uris: [input.redirectUri], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }; +} + +export interface CimdReachability { + readonly reachable: boolean; + /** Machine-readable reason when `reachable` is false. Null when reachable. */ + readonly reason: CimdBlockedReason | null; +} + +export type CimdBlockedReason = + | 'no_public_base_url' + | 'not_public_https' + | 'fetch_failed' + | 'document_mismatch'; + +/** + * Can an authorization server actually FETCH our metadata document? + * + * The honest answer cannot be known from inside the process — only the IdP's + * own network can answer it. What IS knowable, and what this checks, are the + * conditions that make the answer definitely "no": + * + * 1. no public base origin configured at all; + * 2. the origin is not a public https host — `assertPublicHttpsUrl` rejects + * plain http, RFC1918 / loopback / link-local / CGNAT literals, `.internal` + * and `.local` names, and hostnames that DNS-resolve into those ranges. + * This is the same guard the discovery chain uses; a second validator would + * be a second thing to keep correct. + * 3. the URL does not serve OUR document — fetched over the public name, so a + * reverse proxy that never routes `/.well-known/*` to the middleware, or a + * DNS name pointing somewhere else entirely, is caught. + * + * A "yes" is therefore a strong necessary condition, not a guarantee; a "no" is + * conclusive, and the caller degrades to the manual path on it. + */ +export async function probeCimdReachable(input: { + readonly metadataUrl: string | null; + readonly fetchImpl?: typeof fetch; + readonly timeoutMs?: number; +}): Promise { + const { metadataUrl } = input; + if (!metadataUrl) return { reachable: false, reason: 'no_public_base_url' }; + try { + await assertPublicHttpsUrl(metadataUrl); + } catch { + return { reachable: false, reason: 'not_public_https' }; + } + const fetchImpl = input.fetchImpl ?? globalThis.fetch; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? PROBE_TIMEOUT_MS); + try { + const res = await fetchImpl(metadataUrl, { + headers: { accept: 'application/json' }, + signal: controller.signal, + // Same rule as every other guarded fetch: a redirect could bounce past + // the SSRF check that was applied to the pre-redirect URL. + redirect: 'error', + }); + if (!res.ok) return { reachable: false, reason: 'fetch_failed' }; + const text = await res.text(); + if (Buffer.byteLength(text, 'utf8') > MAX_DOCUMENT_BYTES) { + return { reachable: false, reason: 'document_mismatch' }; + } + const doc = JSON.parse(text) as unknown; + if (!doc || typeof doc !== 'object') { + return { reachable: false, reason: 'document_mismatch' }; + } + // The document must claim the very URL we fetched. A generic 200 from a + // catch-all proxy route otherwise reads as success. + if ((doc as { client_id?: unknown }).client_id !== metadataUrl) { + return { reachable: false, reason: 'document_mismatch' }; + } + return { reachable: true, reason: null }; + } catch { + return { reachable: false, reason: 'fetch_failed' }; + } finally { + clearTimeout(timer); + } +} + +/** + * TTL cache around {@link probeCimdReachable}. The probe is one outbound HTTPS + * request; `describeAuth` runs on every status poll of the MCP Control Center, + * so an uncached probe would put the admin UI's refresh rate on our own + * ingress. + */ +export class CimdReachabilityCache { + private cached: { at: number; value: CimdReachability } | null = null; + private inFlight: Promise | null = null; + + constructor( + private readonly metadataUrl: string | null, + private readonly deps: { fetchImpl?: typeof fetch; ttlMs?: number } = {}, + ) {} + + /** The metadata URL this cache probes (null when CIMD is unconfigured). */ + get url(): string | null { + return this.metadataUrl; + } + + async get(): Promise { + const ttl = this.deps.ttlMs ?? REACHABILITY_TTL_MS; + const hit = this.cached; + if (hit && Date.now() - hit.at < ttl) return hit.value; + // Single-flight: concurrent status polls otherwise each fire their own + // probe against our own ingress. + const existing = this.inFlight; + if (existing) return existing; + const attempt = probeCimdReachable({ + metadataUrl: this.metadataUrl, + ...(this.deps.fetchImpl ? { fetchImpl: this.deps.fetchImpl } : {}), + }) + .then((value) => { + this.cached = { at: Date.now(), value }; + return value; + }) + .finally(() => { + this.inFlight = null; + }); + this.inFlight = attempt; + return attempt; + } +} diff --git a/middleware/src/services/mcpDelegation.ts b/middleware/src/services/mcpDelegation.ts new file mode 100644 index 00000000..ceff17e6 --- /dev/null +++ b/middleware/src/services/mcpDelegation.ts @@ -0,0 +1,94 @@ +/** + * Which identity an MCP call acts as (W0-1, D2) — the confused-deputy fix. + * + * Before this, both the operator router and the runtime McpManager resolved the + * OAuth user key as ` ?? 'operator'`. That fallback is the bug: a + * Teams or Telegram turn whose user has no mapped identity would silently reach + * the customer's MCP server holding the OPERATOR's token — full operator + * authority, granted to whoever happened to be typing in a channel. + * + * Resolution is now explicit and per server: + * + * delegation = 'service' one shared identity, deliberately opted into. The + * key stays `operator`, so servers grandfathered by + * migration 0031 keep the token they already have. + * + * delegation = 'per_user' the caller's own identity or nothing. An + * unresolvable identity yields `null`, the caller + * gets no token, and the call fails closed through + * the existing `onAuthFailure` path. + * + * There is deliberately no third branch. Every path that needs a user key goes + * through `resolveMcpUserKey`, so the fallback cannot reappear by accident. + */ + +import type { McpDelegation, McpServerRow } from '@omadia/orchestrator'; + +/** The shared key used when a server opts into `service` delegation. Matches + * the historical literal so pre-0031 stored tokens keep resolving. */ +export const SERVICE_USER_KEY = 'operator'; + +/** Recorded in the audit trail when a `per_user` server had no identity to act + * as. A row that simply said nothing would hide exactly the case operators + * need to find. */ +export const UNRESOLVED_IDENTITY = 'unresolved'; + +/** Just the delegation-relevant slice of a server row, so callers (and tests) + * need not build a whole `McpServerRow`. */ +export interface DelegationTarget { + readonly delegation: McpDelegation; +} + +/** + * The identity this call acts as, or `null` when a `per_user` server has no + * resolvable caller. + * + * @param server the target server (its `delegation` mode decides). + * @param candidate the caller's own identity — a session `sub`/`email`, or the + * turn context's `mcpUserKey`. Blank/whitespace counts as + * absent. + * @param serviceKey the shared key for `service` delegation. Defaults to the + * historical `operator` literal so grandfathered tokens keep + * resolving; only override it in tests. + */ +export function resolveMcpUserKey( + server: DelegationTarget, + candidate: string | null | undefined, + serviceKey: string = SERVICE_USER_KEY, +): string | null { + if (server.delegation === 'service') return serviceKey; + const trimmed = typeof candidate === 'string' ? candidate.trim() : ''; + return trimmed === '' ? null : trimmed; +} + +/** The identity to write to `mcp_call_log`. Never null-by-omission: an + * unattributable call is recorded AS unattributable. */ +export function auditIdentity( + server: DelegationTarget, + candidate: string | null | undefined, + serviceKey: string = SERVICE_USER_KEY, +): string { + return resolveMcpUserKey(server, candidate, serviceKey) ?? UNRESOLVED_IDENTITY; +} + +/** Operator-facing explanation when a `per_user` server has no caller identity. + * Returned through `onAuthFailure`, so the turn fails closed with a reason + * instead of silently borrowing the operator's authority. */ +export function delegationBlockedMessage(serverName: string): string { + return ( + `🔒 The MCP server "${serverName}" is set to per-user delegation, but this conversation has no ` + + `mapped user identity, so there is no one to act as. Nothing was sent to the server. ` + + `Either sign in through a channel that maps your identity, or have an operator switch this ` + + `server to a shared service identity in the MCP Control Center.` + ); +} + +/** Narrow an untrusted string to a delegation mode. */ +export function parseDelegation(value: unknown): McpDelegation | null { + return value === 'per_user' || value === 'service' ? value : null; +} + +/** Convenience for callers that hold a full row. */ +export function serverDelegation(server: Pick): McpDelegation { + return server.delegation; +} diff --git a/middleware/src/services/mcpOAuthService.ts b/middleware/src/services/mcpOAuthService.ts index 52db4689..ac57665b 100644 --- a/middleware/src/services/mcpOAuthService.ts +++ b/middleware/src/services/mcpOAuthService.ts @@ -12,6 +12,12 @@ */ import { McpAuthDiscovery, serverOrigin, type DiscoveredAuth } from './mcpAuthDiscovery.js'; import { McpOAuthClient, type OAuthClientCredentials } from './mcpOAuthClient.js'; +import { + CIMD_CLIENT_NAME, + CimdReachabilityCache, + type CimdBlockedReason, +} from './mcpCimd.js'; +import { redactedErrorText } from './secretRedaction.js'; import { substituteMcpConfig } from '../agents/subAgentToolHydration.js'; import type { AgentGraphStore, McpServerRow } from '@omadia/orchestrator'; @@ -34,9 +40,38 @@ export interface McpOAuthServiceDeps { readonly redirectUri: string; readonly discovery?: McpAuthDiscovery; readonly client?: McpOAuthClient; + /** W2-4 — the stable CIMD metadata-document URL, i.e. the `client_id` a + * CIMD-capable authorization server dereferences. Null/undefined disables the + * cimd link of the acquisition chain entirely, which is the correct state for + * any install without inbound https reachability. */ + readonly cimdMetadataUrl?: string | null; + /** Injected only by tests, to drive the reachability probe without a real + * ingress. Production uses global fetch. */ + readonly cimdFetchImpl?: typeof fetch; readonly log?: (msg: string) => void; } +/** Which link of the acquisition chain produced (or would produce) the OAuth + * client for an issuer — surfaced to the UI via {@link McpOAuthService.describeAuth} + * so an operator can see WHY a server needs setup, or why it does not. */ +export type McpClientAcquisitionMode = 'stored' | 'cimd' | 'dcr' | 'manual'; + +export interface McpAuthDescription { + readonly protected: boolean; + readonly issuer: string | null; + readonly issuerHost: string | null; + readonly brokered: boolean; + /** The chain link this issuer resolves through. `manual` means an operator + * must register an app — the Entra ID / Okta steady state, not a failure. */ + readonly acquisitionMode: McpClientAcquisitionMode; + /** True when the AS advertised `client_id_metadata_document_supported`. + * Independent of whether OUR side can serve the document. */ + readonly cimdSupported: boolean; + /** Why CIMD is unavailable despite the AS advertising it — `null` when it is + * available or the AS never advertised it. Drives the UI diagnostic. */ + readonly cimdBlockedReason: CimdBlockedReason | null; +} + export interface BeginAuthResult { readonly authorizeUrl: string; } @@ -50,6 +85,40 @@ export class McpOAuthNeedsClientError extends Error { } } +/** + * RFC 9207 issuer validation failed at the callback (W0-1, D1). + * + * The authorization response either carried an `iss` naming a DIFFERENT + * authorization server than the one this flow was started against, or omitted + * `iss` entirely although that AS advertised support for it. Both are the + * mix-up signature: a malicious or compromised MCP server steering the + * callback so a code minted by one AS is redeemed at another. + * + * This is thrown BEFORE the code is exchanged, so nothing is ever persisted. + */ +export class McpOAuthIssuerMismatchError extends Error { + constructor( + readonly expected: string, + readonly received: string | null, + ) { + super( + received === null + ? `authorization response omitted the "iss" parameter although issuer "${expected}" advertises RFC 9207 support` + : `authorization response issuer "${received}" does not match the issuer this flow was started against ("${expected}")`, + ); + this.name = 'McpOAuthIssuerMismatchError'; + } +} + +/** RFC 9207 §2.4: compare issuer identifiers exactly, modulo one trailing + * slash (`https://as.example` and `https://as.example/` are the same AS). + * Deliberately NOT a loose/normalizing comparison — that would reintroduce + * the mix-up the check exists to prevent. */ +function sameIssuer(a: string, b: string): boolean { + const norm = (s: string): string => s.trim().replace(/\/+$/, ''); + return norm(a) !== '' && norm(a) === norm(b); +} + const DCR_PROBE_TTL_MS = 10 * 60 * 1000; export class McpOAuthService { @@ -61,13 +130,35 @@ export class McpOAuthService { * the advertised flag. Cached to avoid re-probing on every status check. */ private readonly dcrProbeCache = new Map(); + /** In-flight refreshes, keyed by (serverId, userKey) — W0-1, D3. + * + * Without this, N concurrent callers whose token just expired each POST the + * SAME refresh token to the token endpoint. Against an AS with rotating + * refresh tokens (the OAuth 2.1 default) the first response invalidates the + * token the others are still using, so the losers get `invalid_grant` and, + * worse, the last writer can persist a refresh token the AS has already + * retired — the user silently ends up disconnected. + * + * Sharing one promise makes exactly one HTTP request per (server, user) + * regardless of caller count. The entry is removed in a `finally` so a + * failed refresh never poisons later attempts. */ + private readonly refreshInFlight = new Map>(); + /** The redirect URI the operator must register with the OAuth provider. */ readonly redirectUri: string; + /** W2-4 — reachability of our own CIMD document, cached. `url` is null when + * no inbound-reachable public base is configured, which permanently disables + * the cimd link without affecting any other mode. */ + private readonly cimd: CimdReachabilityCache; + constructor(private readonly deps: McpOAuthServiceDeps) { this.discovery = deps.discovery ?? new McpAuthDiscovery(); this.client = deps.client ?? new McpOAuthClient(); this.redirectUri = deps.redirectUri; + this.cimd = new CimdReachabilityCache(deps.cimdMetadataUrl ?? null, { + ...(deps.cimdFetchImpl ? { fetchImpl: deps.cimdFetchImpl } : {}), + }); } private tokenRef(serverId: string, userKey: string, kind: 'access' | 'refresh'): string { @@ -96,7 +187,10 @@ export class McpOAuthService { } /** A live access token for (server, user), refreshing if near expiry, or null - * when the user has not authorized. */ + * when the user has not authorized. + * + * Concurrent callers that all need a refresh share ONE refresh (D3) — see + * `refreshInFlight`. */ async getValidAccessToken(server: McpServerRow, userKey: string): Promise { const row = await this.deps.graph.getMcpOAuthToken(server.id, userKey); if (!row) return null; @@ -109,20 +203,53 @@ export class McpOAuthService { if (!row.refreshTokenRef || !server.endpoint) { return (await this.deps.vault.get(VAULT_NS, row.accessTokenRef)) ?? null; } - const refreshToken = await this.deps.vault.get(VAULT_NS, row.refreshTokenRef); - if (!refreshToken) return (await this.deps.vault.get(VAULT_NS, row.accessTokenRef)) ?? null; - try { - const discovered = await this.discovery.discover(this.resolveEndpoint(server)); - if (!discovered) return null; - const client = await this.loadClient(discovered.server.issuer); - if (!client) return null; - const tok = await this.client.refresh({ server: discovered.server, client, refreshToken }); - await this.persistToken(server.id, userKey, tok); - return tok.accessToken; - } catch (err) { - this.deps.log?.(`[mcpOAuth] refresh failed for ${server.name}: ${String(err)}`); - return (await this.deps.vault.get(VAULT_NS, row.accessTokenRef)) ?? null; - } + + // ── single-flight (W0-1, D3) ──────────────────────────────────────────── + // Everything below runs at most once per (server, user) at a time. The + // map is checked and populated synchronously — no `await` between the get + // and the set — so two callers in the same tick cannot both miss. + const key = `${server.id}${userKey}`; + const existing = this.refreshInFlight.get(key); + if (existing) return existing; + + const refreshRefKey = row.refreshTokenRef; + const accessRefKey = row.accessTokenRef; + const attempt = (async (): Promise => { + const refreshToken = await this.deps.vault.get(VAULT_NS, refreshRefKey); + if (!refreshToken) return (await this.deps.vault.get(VAULT_NS, accessRefKey)) ?? null; + try { + const discovered = await this.discovery.discover(this.resolveEndpoint(server)); + if (!discovered) return null; + // The issuer rotated since this token was minted (W0-1): the stored + // token belongs to a different authorization server, so replaying it + // here would send one AS's credential to another. Drop it and make the + // user re-authorize against the new issuer. + if (row.issuer !== null && !sameIssuer(row.issuer, discovered.server.issuer)) { + this.deps.log?.( + `[mcpOAuth] issuer rotated for ${server.name} (stored ${row.issuer} → discovered ${discovered.server.issuer}); dropping the stored token`, + ); + await this.deps.graph.deleteMcpOAuthToken(server.id, userKey); + return null; + } + const client = await this.loadClient(discovered.server.issuer); + if (!client) return null; + const tok = await this.client.refresh({ server: discovered.server, client, refreshToken }); + await this.persistToken(server.id, userKey, tok, discovered.server.issuer); + return tok.accessToken; + } catch (err) { + // D5: an OAuth error body routinely echoes the token back — never let + // `String(err)` reach a log line unredacted. + this.deps.log?.( + `[mcpOAuth] refresh failed for ${server.name}: ${redactedErrorText(err, [refreshToken])}`, + ); + return (await this.deps.vault.get(VAULT_NS, accessRefKey)) ?? null; + } + })().finally(() => { + this.refreshInFlight.delete(key); + }); + + this.refreshInFlight.set(key, attempt); + return attempt; } /** Start the authorization flow: returns the URL to send the user to. */ @@ -156,6 +283,11 @@ export class McpOAuthService { // that a malicious server could have switched in the meantime. tokenEndpoint: discovered.server.tokenEndpoint, authorizationEndpoint: discovered.server.authorizationEndpoint, + // RFC 9207 (W0-1, D1): remember whether THIS authorization server + // promised to send `iss`, captured now rather than re-discovered at the + // callback — a server that could flip the flag in between would simply + // opt itself out of the check. + issRequired: discovered.server.issParameterSupported, }); return { authorizeUrl: url }; } @@ -163,11 +295,35 @@ export class McpOAuthService { /** Finish the flow at the callback: exchange the code and store the token. * Uses the endpoints captured when the flow started — NOT a fresh discovery * (codex W9 critical fold: a malicious server could otherwise switch its - * token endpoint to steal the code + PKCE verifier + client secret). */ - async completeAuthorization(state: string, code: string): Promise<{ serverId: string }> { + * token endpoint to steal the code + PKCE verifier + client secret). + * + * @param iss the RFC 9207 `iss` authorization-response parameter, or null + * when the provider sent none. Validated against the issuer bound to the + * flow BEFORE the code is exchanged, so a mismatch persists nothing. */ + async completeAuthorization( + state: string, + code: string, + iss?: string | null, + ): Promise<{ serverId: string }> { const flow = await this.deps.graph.takeMcpOAuthFlow(state); if (!flow) throw new Error('unknown or expired authorization state'); if (!flow.tokenEndpoint) throw new Error('flow is missing its bound token endpoint'); + // ── RFC 9207 issuer validation (W0-1, D1) ─────────────────────────────── + // `state` alone proves only that the response came back to a flow we + // started; it does NOT prove WHICH authorization server issued the code. + // A malicious MCP server can steer the browser so a code minted by one AS + // is redeemed at another. Runs before the exchange — a rejected callback + // must leave no token behind. + const received = typeof iss === 'string' && iss.trim() !== '' ? iss.trim() : null; + if (received !== null) { + if (!sameIssuer(flow.issuer, received)) { + throw new McpOAuthIssuerMismatchError(flow.issuer, received); + } + } else if (flow.issRequired) { + // The AS advertised RFC 9207 support and then did not send `iss` — + // either a stripped parameter or a response that never came from it. + throw new McpOAuthIssuerMismatchError(flow.issuer, null); + } const client = await this.loadClient(flow.issuer); if (!client) throw new McpOAuthNeedsClientError(flow.issuer); // Reconstruct the minimal server metadata from the FLOW-BOUND values. @@ -179,6 +335,15 @@ export class McpOAuthService { codeChallengeMethods: [] as string[], grantTypes: [] as string[], scopesSupported: [] as string[], + // Irrelevant for the exchange itself; the `iss` decision was already + // made above from the flow's persisted `issRequired`. + issParameterSupported: flow.issRequired, + // Also irrelevant at exchange time: whether the AS dereferences a client + // metadata document only matters when ACQUIRING a client. The client is + // already resolved (loadClient above), so re-asserting a capability here + // could only mislead — pinned false rather than re-discovered, same + // reasoning as the endpoint binding. + clientIdMetadataDocumentSupported: false, }; const tok = await this.client.exchangeCode({ server: boundServer, @@ -187,7 +352,7 @@ export class McpOAuthService { codeVerifier: flow.codeVerifier, redirectUri: flow.redirectUri, }); - await this.persistToken(flow.serverId, flow.userKey, tok); + await this.persistToken(flow.serverId, flow.userKey, tok, flow.issuer); return { serverId: flow.serverId }; } @@ -201,19 +366,45 @@ export class McpOAuthService { return { clientId: row.clientId, clientSecret: secret }; } - /** Return an OAuth client for the issuer: an existing one, a fresh DCR - * registration, or throw McpOAuthNeedsClientError when neither is possible. */ + /** + * The client-acquisition strategy chain (W2-4): + * + * stored → cimd → dcr (deprecated, warns) → manual → McpOAuthNeedsClientError + * + * Order is not arbitrary. `stored` first so an already-working install never + * re-negotiates. `cimd` before `dcr` because the MCP spec deprecates DCR in + * favour of it and CIMD costs one local document instead of a write at the + * provider. `dcr` still runs — the deprecation is on a 12-month clock, and + * removing it would break every broker that has not migrated. `manual` is + * last only because it is the one link that needs a human; it is NOT a + * fallback of last resort in the sense of being second-class. For Entra ID and + * Okta — neither of which supports CIMD — manual is the ONLY correct path and + * has no sunset. + */ private async ensureClient(discovered: DiscoveredAuth): Promise { const issuer = discovered.server.issuer; + + // ── stored ──────────────────────────────────────────────────────────────── const existing = await this.loadClient(issuer); if (existing) return existing; - // Try dynamic client registration (RFC 7591) — zero-config path. + + // ── cimd ────────────────────────────────────────────────────────────────── + const cimdClient = await this.tryCimdClient(discovered); + if (cimdClient) return cimdClient; + + // ── dcr (deprecated, still supported) ───────────────────────────────────── const registered = await this.client.registerClient( discovered.server, this.deps.redirectUri, - 'omadia MCP', + CIMD_CLIENT_NAME, ); if (registered) { + // Deprecation warning, not an error: the MCP spec's DCR sunset is a + // 12-month clock, and a broker that only offers DCR must keep working + // until it migrates. + this.deps.log?.( + `[mcpOAuth] issuer ${issuer} was acquired via RFC 7591 Dynamic Client Registration, which the MCP authorization spec deprecates in favour of Client ID Metadata Documents. It keeps working; nothing to do today.`, + ); const secretRef = registered.clientSecret ? this.clientSecretRef(issuer) : null; if (secretRef && registered.clientSecret) { await this.deps.vault.set(VAULT_NS, secretRef, registered.clientSecret); @@ -223,13 +414,73 @@ export class McpOAuthService { clientId: registered.clientId, clientSecretRef: secretRef, registeredVia: 'dcr', + clientMetadataUrl: null, }); return registered; } + + // ── manual ──────────────────────────────────────────────────────────────── + // Nothing automatic applies. `setManualClient` is the operator's entry + // point; this error is what the route turns into the client-registration + // form, so it is a prompt, not a fault. throw new McpOAuthNeedsClientError(issuer); } - /** Operator-provided client for an issuer that lacks DCR (one-time). */ + /** + * The `cimd` link. Returns null (never throws) whenever CIMD does not apply, + * so the chain simply moves on: + * + * - the AS never advertised `client_id_metadata_document_supported`; + * - no metadata URL is configured (`FLOW_PUBLIC_BASE_URL` unset); + * - the document is not inbound-reachable — the on-prem / firewalled reality. + * + * There is no HTTP call to the provider here: a CIMD `client_id` needs no + * registration step at all. We persist the URL as the client_id and the AS + * dereferences it at authorize time. + */ + private async tryCimdClient( + discovered: DiscoveredAuth, + ): Promise { + if (!discovered.server.clientIdMetadataDocumentSupported) return null; + const metadataUrl = this.cimd.url; + if (!metadataUrl) { + this.deps.log?.( + `[mcpOAuth] issuer ${discovered.server.issuer} supports Client ID Metadata Documents, but no inbound-reachable public base URL is configured (set FLOW_PUBLIC_BASE_URL) — falling through to the manual client path.`, + ); + return null; + } + const reach = await this.cimd.get(); + if (!reach.reachable) { + this.deps.log?.( + `[mcpOAuth] issuer ${discovered.server.issuer} supports Client ID Metadata Documents, but ${metadataUrl} is not inbound-reachable (${reach.reason}) — falling through to the manual client path.`, + ); + return null; + } + // A CIMD client is public by construction: the document is world-readable, + // so there is no secret to hold and PKCE alone protects the exchange. + // `clientSecretRef` stays null — nothing is written to the vault. + await this.deps.graph.upsertMcpOAuthClient({ + issuer: discovered.server.issuer, + clientId: metadataUrl, + clientSecretRef: null, + registeredVia: 'cimd', + clientMetadataUrl: metadataUrl, + }); + this.deps.log?.( + `[mcpOAuth] issuer ${discovered.server.issuer} acquired via Client ID Metadata Document ${metadataUrl}`, + ); + return { clientId: metadataUrl, clientSecret: null }; + } + + /** + * Operator-provided client for an issuer, registered once by hand. + * + * W2-4: this is a FIRST-CLASS, PERMANENT path, not a legacy fallback. Entra ID + * and Okta do not support Client ID Metadata Documents and never will need to + * — they use pre-registered app registrations, which is exactly this. CIMD + * only replaces Dynamic Client Registration at MCP-native brokers. Do not + * deprecate or gate this behind a CIMD-unavailable check. + */ async setManualClient(issuer: string, clientId: string, clientSecret: string | null): Promise { let secretRef: string | null = null; if (clientSecret) { @@ -241,6 +492,7 @@ export class McpOAuthService { clientId, clientSecretRef: secretRef, registeredVia: 'manual', + clientMetadataUrl: null, }); } @@ -259,28 +511,37 @@ export class McpOAuthService { /** * Classify a server's auth so the UI can explain the tradeoff: * - protected=false → no authorization needed. - * - brokered=true → the server offers Dynamic Client Registration, - * so connecting is zero-setup (it holds its own downstream app). - * - brokered=false → the server delegates raw to its issuer with no - * DCR, so a one-time operator OAuth app is required (a weaker server). + * - brokered=true → a client can be acquired with NO operator setup: + * either a Client ID Metadata Document (W2-4) or working DCR. + * - brokered=false → a one-time operator OAuth app is required. For + * Entra ID / Okta this is the normal, permanent path — not a defect. + * + * `acquisitionMode` names WHICH link of the chain applies, and + * `cimdBlockedReason` explains a CIMD-capable issuer we still cannot use + * (almost always: no inbound https reachability on this install). * `issuerHost` is the human-readable host the OAuth actually goes to. */ - async describeAuth( - server: McpServerRow, - ): Promise<{ protected: boolean; issuer: string | null; issuerHost: string | null; brokered: boolean }> { - if (!server.endpoint) return { protected: false, issuer: null, issuerHost: null, brokered: false }; + async describeAuth(server: McpServerRow): Promise { + const unprotected: McpAuthDescription = { + protected: false, + issuer: null, + issuerHost: null, + brokered: false, + acquisitionMode: 'manual', + cimdSupported: false, + cimdBlockedReason: null, + }; + if (!server.endpoint) return unprotected; // stdio servers are local commands, not OAuth-protected HTTP endpoints — // never run OAuth discovery/connect for them (epic #459). - if (server.transport === 'stdio') { - return { protected: false, issuer: null, issuerHost: null, brokered: false }; - } + if (server.transport === 'stdio') return unprotected; let discovered; try { discovered = await this.discovery.discover(this.resolveEndpoint(server)); } catch { - return { protected: true, issuer: null, issuerHost: null, brokered: false }; + return { ...unprotected, protected: true }; } - if (!discovered) return { protected: false, issuer: null, issuerHost: null, brokered: false }; + if (!discovered) return unprotected; const issuer = discovered.server.issuer; let issuerHost: string | null = null; try { @@ -288,23 +549,63 @@ export class McpOAuthService { } catch { /* keep null */ } + + // An already-stored client short-circuits: report the mode it was acquired + // through rather than re-probing anything. + const storedRow = await this.deps.graph.getMcpOAuthClient(issuer); + if (storedRow) { + return { + protected: true, + issuer, + issuerHost, + brokered: storedRow.registeredVia !== 'manual', + acquisitionMode: storedRow.registeredVia === 'manual' ? 'manual' : storedRow.registeredVia, + cimdSupported: discovered.server.clientIdMetadataDocumentSupported, + cimdBlockedReason: null, + }; + } + + const cimdSupported = discovered.server.clientIdMetadataDocumentSupported; + let cimdBlockedReason: CimdBlockedReason | null = null; + if (cimdSupported) { + const reach = await this.cimd.get(); + if (reach.reachable) { + // CIMD is live for this issuer — zero operator setup, and the manual + // form must NOT be shown as if it were required. + return { + protected: true, + issuer, + issuerHost, + brokered: true, + acquisitionMode: 'cimd', + cimdSupported: true, + cimdBlockedReason: null, + }; + } + cimdBlockedReason = reach.reason; + } + + // "brokered" via DCR = DCR REALLY works, not just that it's advertised. + // Probe it (result cached) so the UI never promises zero-setup for a server + // whose registration is gated. + const dcrWorks = + discovered.server.registrationEndpoint !== null && (await this.canBrokerClient(discovered)); return { protected: true, issuer, issuerHost, - // "brokered" = DCR REALLY works, not just that it's advertised. Probe it - // (result cached) so the UI never promises zero-setup for a server whose - // registration is gated. - brokered: - discovered.server.registrationEndpoint !== null && - (await this.canBrokerClient(discovered)), + brokered: dcrWorks, + acquisitionMode: dcrWorks ? 'dcr' : 'manual', + cimdSupported, + cimdBlockedReason, }; } /** True when we can obtain an OAuth client for this issuer WITHOUT operator - * setup — either one is already stored, or Dynamic Client Registration - * actually succeeds. A success also persists the client, so a later Connect - * is instant. Failure (e.g. a gated DCR endpoint) is cached as not-brokered. */ + * setup — either one is already stored, or the acquisition chain (cimd, then + * DCR) actually succeeds. A success also persists the client, so a later + * Connect is instant. Failure (e.g. a gated DCR endpoint) is cached as + * not-brokered. */ private async canBrokerClient(discovered: DiscoveredAuth): Promise { const issuer = discovered.server.issuer; try { @@ -328,6 +629,9 @@ export class McpOAuthService { serverId: string, userKey: string, tok: { accessToken: string; refreshToken: string | null; expiresInSec: number | null; scope: string | null }, + /** Issuer that minted this token (W0-1) — recorded so a later issuer + * rotation invalidates it instead of replaying it at a different AS. */ + issuer?: string | null, ): Promise { const accessRef = this.tokenRef(serverId, userKey, 'access'); await this.deps.vault.set(VAULT_NS, accessRef, tok.accessToken); @@ -343,6 +647,7 @@ export class McpOAuthService { refreshTokenRef: refreshRef, expiresAt: tok.expiresInSec ? new Date(Date.now() + tok.expiresInSec * 1000) : null, scopes: tok.scope, + issuer: issuer ?? null, }); } diff --git a/middleware/src/services/mcpRegistryClient.ts b/middleware/src/services/mcpRegistryClient.ts index 52042a43..3d13556b 100644 --- a/middleware/src/services/mcpRegistryClient.ts +++ b/middleware/src/services/mcpRegistryClient.ts @@ -12,7 +12,7 @@ * official API. */ -import type { McpConfigField } from '@omadia/orchestrator'; +import { isDeprecatedMcpTransport, type McpConfigField } from '@omadia/orchestrator'; export interface McpRegistryConfig { readonly id: string; @@ -33,6 +33,10 @@ export interface McpCatalogEntry { /** Derived connection candidate; null when the entry only ships packages * we cannot translate into a transport (then it is browse-only). */ readonly transport: 'http' | 'sse' | 'stdio' | null; + /** Issue #541 — the derived transport is deprecated by MCP 2026-07-28. Only + * true when the entry offers no non-deprecated alternative; the import is + * still allowed (removal window open), the operator just gets warned. */ + readonly transportDeprecated: boolean; readonly endpoint: string | null; readonly license: string | null; readonly author: string | null; @@ -100,6 +104,56 @@ function deriveAuthor(name: string, repoUrl: string | null): string | null { return ghRepo?.[1] ?? null; } +type RemoteCandidate = { + readonly transport: 'http' | 'sse'; + readonly endpoint: string; +}; + +/** + * Pick the connection candidate from a catalog entry's `remotes[]`. + * + * Second registration path for issue #541: a marketplace/catalog import is the + * other way an `sse` row can be minted, so the deprecation has to be enforced + * here too — a UI-only change would keep importing legacy SSE servers. + * + * When an entry advertises BOTH a Streamable-HTTP and a legacy HTTP+SSE remote + * we now take the `http` one (MCP 2026-07-28 deprecated HTTP+SSE, Streamable + * HTTP is the migration target). `sse` is still returned when it is the only + * remote offered — nothing is hard-blocked while the removal window is open; + * the row is flagged `transportDeprecated` instead. + * + * Every candidate must clear the same UNTRUSTED-remote validation as before + * (https only, host not internal/metadata); unlike the previous version this + * scans all remotes rather than only the first, which is what makes the + * preference possible and also rescues entries whose first remote is malformed. + */ +function pickRemoteCandidate(remotes: readonly unknown[]): RemoteCandidate | null { + const candidates: RemoteCandidate[] = []; + for (const r of remotes) { + if (!r || typeof r !== 'object') continue; + const remote = r as Record; + const kind = str(remote['type'] ?? remote['transport_type'] ?? remote['transport']); + const url = str(remote['url']); + // Catalog entries are UNTRUSTED (codex fold): only well-formed https + // remotes become endpoints — a catalog must not be able to point the + // middleware at plain-http, custom schemes, or metadata addresses. + if (!url || !kind) continue; + try { + const parsed = new URL(url); + // https only, and the host must clear the untrusted-remote block — + // an untrusted catalog must not yield an internal/metadata endpoint. + if (parsed.protocol !== 'https:' || !isUntrustedRemoteHostSafe(parsed.hostname)) continue; + candidates.push({ transport: kind.includes('sse') ? 'sse' : 'http', endpoint: url }); + } catch { + /* malformed remote URL → not a candidate */ + } + } + // Prefer the first non-deprecated candidate; fall back to the first overall. + return ( + candidates.find((c) => !isDeprecatedMcpTransport(c.transport)) ?? candidates[0] ?? null + ); +} + function normalizeEntry(raw: Record): McpCatalogEntry | null { // Official API wraps the server.json under `server`; accept both. const server = (raw['server'] ?? raw) as Record; @@ -111,28 +165,10 @@ function normalizeEntry(raw: Record): McpCatalogEntry | null { let transport: McpCatalogEntry['transport'] = null; let endpoint: string | null = null; const remotes = Array.isArray(server['remotes']) ? server['remotes'] : []; - const remote = remotes.find( - (r): r is Record => !!r && typeof r === 'object', - ); - if (remote) { - const kind = str(remote['type'] ?? remote['transport_type'] ?? remote['transport']); - const url = str(remote['url']); - // Catalog entries are UNTRUSTED (codex fold): only well-formed https - // remotes become endpoints — a catalog must not be able to point the - // middleware at plain-http, custom schemes, or metadata addresses. - if (url && kind) { - try { - const parsed = new URL(url); - // https only, and the host must clear the untrusted-remote block — - // an untrusted catalog must not yield an internal/metadata endpoint. - if (parsed.protocol === 'https:' && isUntrustedRemoteHostSafe(parsed.hostname)) { - transport = kind.includes('sse') ? 'sse' : 'http'; - endpoint = url; - } - } catch { - /* malformed remote URL → browse-only entry */ - } - } + const picked = pickRemoteCandidate(remotes); + if (picked) { + transport = picked.transport; + endpoint = picked.endpoint; } if (!endpoint) { const packages = Array.isArray(server['packages']) ? server['packages'] : []; @@ -174,6 +210,7 @@ function normalizeEntry(raw: Record): McpCatalogEntry | null { str(server['version']) ?? str((server['version_detail'] as Record | undefined)?.['version']), transport, + transportDeprecated: transport !== null && isDeprecatedMcpTransport(transport), endpoint, license: str(server['license']) ?? str(raw['license']), author: deriveAuthor(name, repoUrl), @@ -221,6 +258,7 @@ function normalizeSmitheryEntry(raw: Record): McpCatalogEntry | version: null, // Remote Smithery servers are streamable-http; endpoint deferred to connect. transport: remote ? 'http' : null, + transportDeprecated: false, endpoint: null, license: null, author: str(raw['owner']) ?? str(raw['namespace']), @@ -516,7 +554,7 @@ export class McpRegistryClient { if (registry.kind === 'smithery') { // A minimal entry; resolveSmitheryEndpoint fills the endpoint + enriches // name/description from the detail doc. - return { id: entryId, name: entryId, description: null, version: null, transport: 'http', endpoint: null, license: null, author: null, sourceUrl: null }; + return { id: entryId, name: entryId, description: null, version: null, transport: 'http', transportDeprecated: false, endpoint: null, license: null, author: null, sourceUrl: null }; } const results = await this.fetchCatalog(registry, entryId); const exact = results.find((e) => e.id === entryId); diff --git a/middleware/src/services/secretRedaction.ts b/middleware/src/services/secretRedaction.ts new file mode 100644 index 00000000..b47b47a4 --- /dev/null +++ b/middleware/src/services/secretRedaction.ts @@ -0,0 +1,77 @@ +/** + * Redaction for anything on its way to a log line in the OAuth path (W0-1, D5). + * + * An OAuth error is one of the most secret-dense strings in the system: a + * provider's error body routinely echoes the `code`, the `code_verifier`, or a + * whole token JSON back at you, and `fetch` failures embed the request URL with + * its query string. `String(err)` therefore cannot go to a log untouched. + * + * Two layers, because either alone leaks: + * 1. EXACT values we already hold (the token we just sent, the verifier we + * generated) — caught wherever they appear, in any encoding shape. + * 2. PATTERNS for values we do NOT hold, because the error came from a server + * that minted them (a rotated refresh token in a JSON error body). + */ + +const REDACTED = '[redacted]'; + +/** Sensitive parameter/field names, matched in JSON bodies and query strings. */ +const SECRET_KEYS = [ + 'access_token', + 'refresh_token', + 'id_token', + 'code_verifier', + 'code_challenge', + 'client_secret', + 'assertion', + 'code', +] as const; + +const KEY_ALTERNATION = SECRET_KEYS.join('|'); + +/** `"access_token":"…"` / `"access_token": '…'` in a JSON-ish body. */ +const JSON_FIELD_RE = new RegExp(`("?(?:${KEY_ALTERNATION})"?\\s*:\\s*)("[^"]*"|'[^']*'|[^,}\\s]+)`, 'gi'); +/** `code=…` / `&refresh_token=…` in a query string or form body. */ +const QUERY_PARAM_RE = new RegExp(`\\b(${KEY_ALTERNATION})=([^&\\s"'}\\]]+)`, 'gi'); +/** `Authorization: Bearer …` echoed back in an error. */ +const BEARER_RE = /\b(bearer\s+)[A-Za-z0-9._~+/-]{8,}=*/gi; + +/** Escape a literal for safe use inside a RegExp. */ +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Redact secrets from arbitrary text before it reaches a log. + * + * @param text the text to sanitize (already stringified). + * @param secrets exact secret values known to the caller (token, verifier, …). + * Short values (< 8 chars) are ignored — redacting them would + * shred unrelated text without protecting anything meaningful. + */ +export function redactSecrets(text: string, secrets: readonly (string | null | undefined)[] = []): string { + let out = text; + for (const secret of secrets) { + if (typeof secret !== 'string' || secret.length < 8) continue; + out = out.replace(new RegExp(escapeRe(secret), 'g'), REDACTED); + // Providers frequently echo the value URL-encoded rather than raw. + const encoded = encodeURIComponent(secret); + if (encoded !== secret) out = out.replace(new RegExp(escapeRe(encoded), 'g'), REDACTED); + } + out = out.replace(JSON_FIELD_RE, (_m, key: string) => `${key}"${REDACTED}"`); + out = out.replace(QUERY_PARAM_RE, (_m, key: string) => `${key}=${REDACTED}`); + out = out.replace(BEARER_RE, (_m, prefix: string) => `${prefix}${REDACTED}`); + return out; +} + +/** + * `String(err)` for a log line, with redaction applied. Use this instead of + * `String(err)` anywhere an OAuth error can reach a logger. + */ +export function redactedErrorText( + err: unknown, + secrets: readonly (string | null | undefined)[] = [], +): string { + const raw = err instanceof Error ? `${err.name}: ${err.message}` : String(err); + return redactSecrets(raw, secrets); +} diff --git a/middleware/test/cliBridge/loopbackMcpServer.test.ts b/middleware/test/cliBridge/loopbackMcpServer.test.ts index 12a79425..ffa237d5 100644 --- a/middleware/test/cliBridge/loopbackMcpServer.test.ts +++ b/middleware/test/cliBridge/loopbackMcpServer.test.ts @@ -19,6 +19,13 @@ function parseMcpJson(text: string): unknown { return JSON.parse(dataLines.join('\n')); } +const MCP_ACCEPT = 'application/json, text/event-stream'; + +/** True when the sandbox refuses loopback listeners, so the test self-skips. */ +function isSandboxListenDenied(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EPERM'; +} + describe('LoopbackMcpServer', () => { let server: LoopbackMcpServer | undefined; @@ -27,7 +34,181 @@ describe('LoopbackMcpServer', () => { server = undefined; }); - it('serves initialize, tools/list, and tools/call over loopback HTTP', async (t) => { + /** + * W1-2 — the loopback transport is stateless (`sessionIdGenerator: + * undefined`), so the wire contract must hold both for a client that + * replays whatever session id the server hands out and for one that never + * sends the header at all. Before the stateless switch the second variant + * failed with HTTP 400 "Mcp-Session-Id header is required". + */ + for (const variant of [ + { + label: 'replaying the session id when the server issues one', + replaySession: true, + }, + { label: 'never sending a session header', replaySession: false }, + ] as const) { + it(`serves initialize, tools/list, and tools/call over loopback HTTP — ${variant.label}`, async (t) => { + const seenCalls: Array<{ name: string; input: unknown }> = []; + const fakeDispatch = { + async dispatch(name: string, input: unknown) { + seenCalls.push({ name, input }); + return { content: `dispatch:${name}:${JSON.stringify(input)}` }; + }, + } as unknown as ToolDispatchService; + + server = new LoopbackMcpServer({ + dispatch: fakeDispatch, + bearer: 'secret-token', + tools: [ + { + name: 'ping', + description: 'p', + input_schema: { type: 'object', properties: {} }, + }, + ], + }); + + let handle: Awaited>; + try { + handle = await server.start(); + } catch (error) { + if (isSandboxListenDenied(error)) { + t.skip('sandbox blocks loopback listeners on 127.0.0.1'); + return; + } + throw error; + } + + const initializeResponse = await fetch(handle.url, { + method: 'POST', + headers: { + Authorization: `Bearer ${handle.bearer}`, + 'Content-Type': 'application/json', + Accept: MCP_ACCEPT, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '0' }, + }, + id: 1, + }), + }); + assert.equal(initializeResponse.status, 200); + const issuedSessionId = initializeResponse.headers.get('mcp-session-id'); + const initializePayload = parseMcpJson( + await initializeResponse.text(), + ) as { + result?: { protocolVersion?: string }; + }; + assert.ok(initializePayload.result); + + // A stateless transport issues no session id at all. Only the replaying + // variant forwards one, and only when the server actually handed it out. + const sessionHeaders: Record = + variant.replaySession && issuedSessionId + ? { 'mcp-session-id': issuedSessionId } + : {}; + + const initializedResponse = await fetch(handle.url, { + method: 'POST', + headers: { + Authorization: `Bearer ${handle.bearer}`, + 'Content-Type': 'application/json', + Accept: MCP_ACCEPT, + ...sessionHeaders, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }), + }); + assert.equal(initializedResponse.status, 202); + + const listResponse = await fetch(handle.url, { + method: 'POST', + headers: { + Authorization: `Bearer ${handle.bearer}`, + 'Content-Type': 'application/json', + Accept: MCP_ACCEPT, + ...sessionHeaders, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 2, + }), + }); + assert.equal(listResponse.status, 200); + const listPayload = parseMcpJson(await listResponse.text()) as { + result?: { tools?: Array<{ name: string }> }; + }; + assert.ok( + listPayload.result?.tools?.some((tool) => tool.name === 'ping'), + ); + + const callResponse = await fetch(handle.url, { + method: 'POST', + headers: { + Authorization: `Bearer ${handle.bearer}`, + 'Content-Type': 'application/json', + Accept: MCP_ACCEPT, + ...sessionHeaders, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'ping', arguments: {} }, + id: 3, + }), + }); + assert.equal(callResponse.status, 200); + const callPayload = parseMcpJson(await callResponse.text()) as { + result?: { + content?: Array<{ type: string; text: string }>; + isError?: boolean; + }; + }; + assert.equal(callPayload.result?.content?.[0]?.text, 'dispatch:ping:{}'); + assert.equal(callPayload.result?.isError, undefined); + assert.deepEqual(seenCalls, [{ name: 'ping', input: {} }]); + + const badBearerResponse = await fetch(handle.url, { + method: 'POST', + headers: { + Authorization: 'Bearer wrong', + 'Content-Type': 'application/json', + Accept: MCP_ACCEPT, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 4, + }), + }); + assert.equal(badBearerResponse.status, 401); + const badBearerPayload = parseMcpJson(await badBearerResponse.text()) as { + error?: { code?: number; message?: string }; + result?: unknown; + }; + assert.equal(badBearerPayload.result, undefined); + assert.equal(badBearerPayload.error?.code, -32001); + assert.equal(badBearerPayload.error?.message, 'Unauthorized'); + }); + } + + /** + * W1-2 criterion — the stateless transport must serve a cold client that + * skips the handshake entirely. Under the previous stateful transport both + * calls below were rejected before ever reaching a request handler. + */ + it('serves tools/list and tools/call with no prior initialize and no session header', async (t) => { const seenCalls: Array<{ name: string; input: unknown }> = []; const fakeDispatch = { async dispatch(name: string, input: unknown) { @@ -52,126 +233,168 @@ describe('LoopbackMcpServer', () => { try { handle = await server.start(); } catch (error) { - if ( - error instanceof Error && - 'code' in error && - error.code === 'EPERM' - ) { + if (isSandboxListenDenied(error)) { t.skip('sandbox blocks loopback listeners on 127.0.0.1'); return; } throw error; } - const initializeResponse = await fetch(handle.url, { + const listResponse = await fetch(handle.url, { method: 'POST', headers: { Authorization: `Bearer ${handle.bearer}`, 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', + Accept: MCP_ACCEPT, }, body: JSON.stringify({ jsonrpc: '2.0', - method: 'initialize', - params: { - protocolVersion: '2025-06-18', - capabilities: {}, - clientInfo: { name: 'test', version: '0' }, - }, + method: 'tools/list', + params: {}, id: 1, }), }); - assert.equal(initializeResponse.status, 200); - const sessionId = initializeResponse.headers.get('mcp-session-id'); - assert.ok(sessionId); - const initializePayload = parseMcpJson(await initializeResponse.text()) as { - result?: { protocolVersion?: string }; + assert.equal(listResponse.status, 200); + const listPayload = parseMcpJson(await listResponse.text()) as { + result?: { tools?: Array<{ name: string }> }; }; - assert.ok(initializePayload.result); + assert.deepEqual( + listPayload.result?.tools?.map((tool) => tool.name), + ['ping'], + ); - const initializedResponse = await fetch(handle.url, { + const callResponse = await fetch(handle.url, { method: 'POST', headers: { Authorization: `Bearer ${handle.bearer}`, 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId, + Accept: MCP_ACCEPT, }, body: JSON.stringify({ jsonrpc: '2.0', - method: 'notifications/initialized', + method: 'tools/call', + params: { name: 'ping', arguments: { a: 1 } }, + id: 2, }), }); - assert.equal(initializedResponse.status, 202); + assert.equal(callResponse.status, 200); + const callPayload = parseMcpJson(await callResponse.text()) as { + result?: { content?: Array<{ type: string; text: string }> }; + }; + assert.equal( + callPayload.result?.content?.[0]?.text, + 'dispatch:ping:{"a":1}', + ); + assert.deepEqual(seenCalls, [{ name: 'ping', input: { a: 1 } }]); + }); + + /** W0-3 — the loopback tool list is advertised name-sorted regardless of + * the order the dispatch service handed the specs over in. */ + it('advertises tools sorted by name', async (t) => { + const fakeDispatch = { + async dispatch() { + return { content: 'ok' }; + }, + } as unknown as ToolDispatchService; + + server = new LoopbackMcpServer({ + dispatch: fakeDispatch, + bearer: 'secret-token', + tools: ['zebra_tool', 'alpha_tool', 'mango_tool'].map((name) => ({ + name, + description: name, + input_schema: { type: 'object', properties: {} }, + })), + }); + + let handle: Awaited>; + try { + handle = await server.start(); + } catch (error) { + if (isSandboxListenDenied(error)) { + t.skip('sandbox blocks loopback listeners on 127.0.0.1'); + return; + } + throw error; + } const listResponse = await fetch(handle.url, { method: 'POST', headers: { Authorization: `Bearer ${handle.bearer}`, 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId, + Accept: MCP_ACCEPT, }, body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, - id: 2, + id: 1, }), }); assert.equal(listResponse.status, 200); const listPayload = parseMcpJson(await listResponse.text()) as { result?: { tools?: Array<{ name: string }> }; }; - assert.ok(listPayload.result?.tools?.some((tool) => tool.name === 'ping')); + assert.deepEqual( + listPayload.result?.tools?.map((tool) => tool.name), + ['alpha_tool', 'mango_tool', 'zebra_tool'], + ); + }); - const callResponse = await fetch(handle.url, { - method: 'POST', - headers: { - Authorization: `Bearer ${handle.bearer}`, - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId, + /** + * W1-2 — the optional GET standalone SSE stream is declined with 405 (which + * the MCP spec allows). Without this the per-request transport would leak: + * a GET stream never ends, so its request scope never tears down. + */ + it('declines the standalone SSE stream with HTTP 405', async (t) => { + const fakeDispatch = { + async dispatch() { + return { content: 'ok' }; }, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'tools/call', - params: { name: 'ping', arguments: {} }, - id: 3, - }), + } as unknown as ToolDispatchService; + + server = new LoopbackMcpServer({ + dispatch: fakeDispatch, + bearer: 'secret-token', + tools: [], }); - assert.equal(callResponse.status, 200); - const callPayload = parseMcpJson(await callResponse.text()) as { - result?: { - content?: Array<{ type: string; text: string }>; - isError?: boolean; - }; - }; - assert.equal(callPayload.result?.content?.[0]?.text, 'dispatch:ping:{}'); - assert.equal(callPayload.result?.isError, undefined); - assert.deepEqual(seenCalls, [{ name: 'ping', input: {} }]); - const badBearerResponse = await fetch(handle.url, { - method: 'POST', + let handle: Awaited>; + try { + handle = await server.start(); + } catch (error) { + if (isSandboxListenDenied(error)) { + t.skip('sandbox blocks loopback listeners on 127.0.0.1'); + return; + } + throw error; + } + + const response = await fetch(handle.url, { + method: 'GET', headers: { - Authorization: 'Bearer wrong', - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', + Authorization: `Bearer ${handle.bearer}`, + Accept: 'text/event-stream', }, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 4, - }), + // A hanging SSE stream would blow this, which is the regression guard. + signal: AbortSignal.timeout(5000), + }); + + assert.equal(response.status, 405); + assert.equal(response.headers.get('allow'), 'POST'); + + // Auth is still checked before the method gate. + const unauthorized = await fetch(handle.url, { + method: 'GET', + headers: { Authorization: 'Bearer wrong' }, + signal: AbortSignal.timeout(5000), }); - assert.equal(badBearerResponse.status, 401); - const badBearerPayload = parseMcpJson(await badBearerResponse.text()) as { - error?: { message?: string }; - result?: unknown; + assert.equal(unauthorized.status, 401); + const payload = parseMcpJson(await unauthorized.text()) as { + error?: { code?: number }; }; - assert.equal(badBearerPayload.result, undefined); - assert.equal(badBearerPayload.error?.message, 'Unauthorized'); + assert.equal(payload.error?.code, -32001); }); it('rejects oversized POST bodies with HTTP 413', async (t) => { @@ -191,11 +414,7 @@ describe('LoopbackMcpServer', () => { try { handle = await server.start(); } catch (error) { - if ( - error instanceof Error && - 'code' in error && - error.code === 'EPERM' - ) { + if (isSandboxListenDenied(error)) { t.skip('sandbox blocks loopback listeners on 127.0.0.1'); return; } diff --git a/middleware/test/cliBridge/toolDispatchPrivacySeam.test.ts b/middleware/test/cliBridge/toolDispatchPrivacySeam.test.ts new file mode 100644 index 00000000..f8e2f657 --- /dev/null +++ b/middleware/test/cliBridge/toolDispatchPrivacySeam.test.ts @@ -0,0 +1,595 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { NativeToolRegistry } from '../../packages/harness-orchestrator/src/nativeToolRegistry.js'; +import type { PrivacyTurnHandle } from '../../packages/harness-orchestrator/src/privacyHandle.js'; +import { + ToolDispatchService, + type ToolDispatchCallerContext, +} from '../../packages/harness-orchestrator/src/toolDispatchService.js'; +import { currentDispatchCaller } from '../../packages/harness-orchestrator/src/toolCallerContext.js'; +import { turnContext } from '../../packages/harness-orchestrator/src/turnContext.js'; +import type { DomainTool } from '../../packages/harness-orchestrator/src/tools/domainQueryTool.js'; + +/** + * #542 prerequisite — the privacy/trace seam in `ToolDispatchService`. + * + * `ToolDispatchService` is what the loopback MCP server dispatches through, and + * what a public MCP endpoint would dispatch through. Before this work it applied + * NO privacy masking: the chat path masks tool results via + * `Orchestrator.dispatchToolDeadlined`, but that code reads its handle from + * `turnContext`, which this dispatcher runs entirely outside of. A caller reaching + * tools here got PII in clear. + * + * MUTATION-CHECK DISCIPLINE: every assertion below inspects the CONTENT that + * leaves the dispatcher. None of them assert "a masking function was called" — + * a call-count assertion stays green over a masking function that returns its + * input unchanged, which is exactly the class of false-green this repo has been + * burned by. The fake handle performs a REAL redaction and the tests assert the + * raw PII is absent from the output. + */ + +const EMAIL = 'erika.mustermann@example.com'; +const IBAN = 'DE89370400440532013000'; +const PII_RESULT = `{"name":"Erika Mustermann","email":"${EMAIL}","iban":"${IBAN}"}`; + +interface RecordedBypass { + readonly toolName: string; + readonly pluginId: string; + readonly bytes: number; +} + +/** + * A privacy handle that genuinely redacts. `internToolResultV4` strips the email + * and IBAN and returns a digest — so if the dispatcher fails to call it, the raw + * values survive into the output and the assertions below fail. + */ +function redactingPrivacyHandle(options?: { + readonly bypassTools?: ReadonlySet; + readonly bypassReceipts?: RecordedBypass[]; + readonly internThrows?: boolean; +}): PrivacyTurnHandle { + return { + async internToolResultV4({ toolName, rawResult }) { + if (options?.internThrows === true) { + throw new Error('privacy provider unavailable'); + } + const redacted = rawResult + .replaceAll(EMAIL, '[masked:email]') + .replaceAll(IBAN, '[masked:iban]') + .replaceAll('Erika Mustermann', '[masked:person]'); + return { + digestText: `«dataset:${toolName}» ${redacted}`, + datasetId: `ds-${toolName}`, + }; + }, + async recordBypassedTool({ toolName, pluginId, bytes }) { + options?.bypassReceipts?.push({ toolName, pluginId, bytes }); + }, + checkBypass(toolName) { + return options?.bypassTools?.has(toolName) === true + ? { pluginId: `plugin-for-${toolName}` } + : undefined; + }, + async runV4Tool() { + throw new Error('not used on this path'); + }, + async subAgentResultV4() { + throw new Error('not used on this path'); + }, + async takeRenderedAnswerV4() { + return undefined; + }, + v4ToolSpecs() { + return []; + }, + async maskUserPrompt() { + return { outcome: 'disabled' }; + }, + async restorePromptPseudonyms(text) { + return text; + }, + snapshotPromptRestorer() { + return undefined; + }, + async finalize() { + return undefined; + }, + }; +} + +function registryWith( + name: string, + result: string, + extra?: { readonly agentId?: string }, +): NativeToolRegistry { + const nativeTools = new NativeToolRegistry(); + nativeTools.register(name, { + handler: async () => result, + spec: { + name, + description: 'returns a PII-bearing payload', + input_schema: { type: 'object', properties: {} }, + }, + domain: 'test.pii', + ...(extra?.agentId !== undefined ? { agentId: extra.agentId } : {}), + }); + return nativeTools; +} + +describe('ToolDispatchService — privacy data-plane boundary (#542 prerequisite)', () => { + it('MASKS a PII-bearing native tool result — the raw values never leave the dispatcher', async () => { + const service = new ToolDispatchService({ + nativeTools: registryWith('odoo_read_partner', PII_RESULT), + domainTools: [], + privacy: () => redactingPrivacyHandle(), + }); + + const result = await service.dispatch('odoo_read_partner', {}); + + // The load-bearing assertions: the actual PII is GONE from the output. + assert.equal( + result.content.includes(EMAIL), + false, + 'the email address reached the caller in clear — masking did not happen', + ); + assert.equal( + result.content.includes(IBAN), + false, + 'the IBAN reached the caller in clear — masking did not happen', + ); + assert.equal( + result.content.includes('Erika Mustermann'), + false, + 'the person name reached the caller in clear — masking did not happen', + ); + // And the masked substitutes ARE present, so this is masking rather than + // the result having been dropped or emptied. + assert.match(result.content, /\[masked:email\]/); + assert.match(result.content, /\[masked:iban\]/); + assert.match(result.content, /«dataset:odoo_read_partner»/); + assert.equal(result.isError, undefined); + }); + + it('MASKS a PII-bearing DOMAIN tool result too (both dispatch branches, not just native)', async () => { + const domainTool: DomainTool = { + name: 'ask_hr', + spec: { + name: 'ask_hr', + description: 'sub-agent', + input_schema: { type: 'object', properties: {}, required: [] }, + }, + domain: 'domain.hr', + async handle() { + return PII_RESULT; + }, + }; + const service = new ToolDispatchService({ + nativeTools: new NativeToolRegistry(), + domainTools: [domainTool], + privacy: () => redactingPrivacyHandle(), + }); + + const result = await service.dispatch('ask_hr', {}); + + assert.equal(result.content.includes(EMAIL), false, 'domain-tool branch leaked the email'); + assert.equal(result.content.includes(IBAN), false, 'domain-tool branch leaked the IBAN'); + assert.match(result.content, /\[masked:email\]/); + }); + + it('inherits an AMBIENT turn privacy handle when no explicit dep is wired', async () => { + const service = new ToolDispatchService({ + nativeTools: registryWith('odoo_read_partner', PII_RESULT), + domainTools: [], + }); + + const result = await turnContext.run( + { + privacyHandle: redactingPrivacyHandle(), + } as unknown as Parameters[0], + () => service.dispatch('odoo_read_partner', {}), + ); + + assert.equal( + result.content.includes(EMAIL), + false, + 'a dispatch inside a turn must inherit that turn privacy handle', + ); + assert.match(result.content, /\[masked:email\]/); + }); + + it('leaves the result UNCHANGED when no privacy provider is installed (parity with the orchestrator)', async () => { + const service = new ToolDispatchService({ + nativeTools: registryWith('odoo_read_partner', PII_RESULT), + domainTools: [], + }); + + const result = await service.dispatch('odoo_read_partner', {}); + + assert.equal(result.content, PII_RESULT); + }); + + it('honours the intern EXEMPTION list — a self/infra tool is not masked', async () => { + // `memory` is on `INTERN_EXEMPT_TOOLS`: masking it would blind the agent to + // its own operational state. The chat path exempts it, so this path must too. + const service = new ToolDispatchService({ + nativeTools: registryWith('memory', PII_RESULT), + domainTools: [], + privacy: () => redactingPrivacyHandle(), + }); + + const result = await service.dispatch('memory', {}); + + assert.equal(result.content, PII_RESULT, 'an intern-exempt tool must pass through raw'); + }); + + it('honours the operator BYPASS and records the receipt entry', async () => { + const receipts: RecordedBypass[] = []; + const service = new ToolDispatchService({ + nativeTools: registryWith('odoo_read_partner', PII_RESULT), + domainTools: [], + privacy: () => + redactingPrivacyHandle({ + bypassTools: new Set(['odoo_read_partner']), + bypassReceipts: receipts, + }), + }); + + const result = await service.dispatch('odoo_read_partner', {}); + + // Bypass means the operator explicitly opted this plugin out — raw is correct. + assert.equal(result.content, PII_RESULT); + // But it must stay auditable, exactly as on the chat path. + assert.deepEqual(receipts, [ + { + toolName: 'odoo_read_partner', + pluginId: 'plugin-for-odoo_read_partner', + bytes: Buffer.byteLength(PII_RESULT, 'utf8'), + }, + ]); + }); + + it('fails OPEN when the privacy provider throws — documented parity with the chat path', async () => { + const service = new ToolDispatchService({ + nativeTools: registryWith('odoo_read_partner', PII_RESULT), + domainTools: [], + privacy: () => redactingPrivacyHandle({ internThrows: true }), + }); + + const result = await service.dispatch('odoo_read_partner', {}); + + // `Orchestrator.dispatchToolDeadlined` logs and sends the raw result when + // interning throws. This path matches it deliberately rather than silently + // diverging; a fail-CLOSED policy for untrusted callers is its own decision. + assert.equal(result.content, PII_RESULT); + assert.equal(result.isError, undefined); + }); +}); + +/** + * W4 — the ERROR path of the same boundary. + * + * `afterDispatch` ran only on the success branch; a THROWING handler returned + * `error.message` verbatim. Handler exceptions are not sanitized: ORMs echo the + * failing row and drivers echo bound parameters, so the message below is an + * ordinary shape for a real Odoo/psql failure — and it went out unmasked. + * + * Same mutation-check discipline as above: every assertion inspects the CONTENT + * that leaves the dispatcher. Deleting the `maskErrorText` call, or making it + * return its input, fails these. + */ +const PII_ERROR = `Fault: Invalid field 'x' on record {"name":"Erika Mustermann","email":"${EMAIL}","iban":"${IBAN}"}`; + +/** A registry whose handler THROWS instead of returning. */ +function throwingRegistryWith(name: string, message: string): NativeToolRegistry { + const nativeTools = new NativeToolRegistry(); + nativeTools.register(name, { + handler: () => { + throw new Error(message); + }, + spec: { + name, + description: 'always fails, with PII in the message', + input_schema: { type: 'object', properties: {} }, + }, + domain: 'test.pii', + }); + return nativeTools; +} + +/** A domain tool whose `handle` THROWS instead of returning. */ +function throwingDomainTool(name: string, message: string): DomainTool { + return { + name, + spec: { + name, + description: 'sub-agent that always fails', + input_schema: { type: 'object', properties: {}, required: [] }, + }, + domain: 'domain.hr', + handle() { + throw new Error(message); + }, + }; +} + +describe('ToolDispatchService — error-path privacy boundary (W4)', () => { + it('MASKS PII out of a NATIVE handler exception message', async () => { + const service = new ToolDispatchService({ + nativeTools: throwingRegistryWith('odoo_search_partner', PII_ERROR), + domainTools: [], + privacy: () => redactingPrivacyHandle(), + }); + + const result = await service.dispatch('odoo_search_partner', {}); + + assert.equal(result.content.includes(EMAIL), false, 'error path leaked the email'); + assert.equal(result.content.includes(IBAN), false, 'error path leaked the IBAN'); + assert.equal( + result.content.includes('Erika Mustermann'), + false, + 'error path leaked the person name', + ); + assert.match(result.content, /\[masked:email\]/, 'the masked digest should have replaced it'); + assert.equal(result.isError, true, 'masking must not swallow the error signal'); + }); + + it('MASKS PII out of a DOMAIN tool exception message too (both branches)', async () => { + const service = new ToolDispatchService({ + nativeTools: new NativeToolRegistry(), + domainTools: [throwingDomainTool('ask_hr', PII_ERROR)], + privacy: () => redactingPrivacyHandle(), + }); + + const result = await service.dispatch('ask_hr', {}); + + assert.equal(result.content.includes(EMAIL), false, 'domain-tool error path leaked the email'); + assert.equal(result.content.includes(IBAN), false, 'domain-tool error path leaked the IBAN'); + assert.match(result.content, /\[masked:email\]/); + assert.equal(result.isError, true); + }); + + it('marks a masked error as `origin: tool` so a consumer knows it had to cross the boundary', async () => { + const service = new ToolDispatchService({ + nativeTools: throwingRegistryWith('odoo_search_partner', PII_ERROR), + domainTools: [], + privacy: () => redactingPrivacyHandle(), + }); + + const result = await service.dispatch('odoo_search_partner', {}); + + assert.equal(result.origin, 'tool'); + }); + + it("marks this service's OWN refusals as `origin: dispatcher` — they carry no tool data", async () => { + const service = new ToolDispatchService({ + nativeTools: new NativeToolRegistry(), + domainTools: [], + privacy: () => redactingPrivacyHandle(), + }); + + const unknown = await service.dispatch('no_such_tool', {}); + assert.equal(unknown.origin, 'dispatcher'); + assert.equal(unknown.isError, true); + + const notReady = new ToolDispatchService({ + nativeTools: registryWith('odoo_read_partner', PII_RESULT, { agentId: '@omadia/odoo' }), + domainTools: [], + privacy: () => redactingPrivacyHandle(), + isPluginToolsReady: () => false, + }); + const unavailable = await notReady.dispatch('odoo_read_partner', {}); + assert.equal(unavailable.origin, 'dispatcher'); + assert.equal(unavailable.isError, true); + }); + + it('does NOT feed the exception text to `captureRawToolResult` — that sink is for tool RESULTS', async () => { + // The KG-ingest / trace consumers behind this callback treat what they get + // as business data. A driver stack trace is not, and reusing the whole + // `afterDispatch` chain would have handed them one. + const captured: string[] = []; + const service = new ToolDispatchService({ + nativeTools: throwingRegistryWith('odoo_search_partner', PII_ERROR), + domainTools: [], + privacy: () => redactingPrivacyHandle(), + captureRawToolResult: (_name, result) => captured.push(result), + }); + + await service.dispatch('odoo_search_partner', {}); + + assert.deepEqual(captured, []); + }); + + it('does NOT honour the operator BYPASS for an exception, and records no receipt', async () => { + // `_privacy_mode: bypass` is consent about a plugin's DECLARED output shape. + // An exception message is arbitrary — anything the driver was holding — so + // the consent does not transfer, and a byte-counted "bypassed" receipt would + // mis-describe what was disclosed. + const receipts: RecordedBypass[] = []; + const service = new ToolDispatchService({ + nativeTools: throwingRegistryWith('odoo_search_partner', PII_ERROR), + domainTools: [], + privacy: () => + redactingPrivacyHandle({ + bypassTools: new Set(['odoo_search_partner']), + bypassReceipts: receipts, + }), + }); + + const result = await service.dispatch('odoo_search_partner', {}); + + assert.equal(result.content.includes(EMAIL), false, 'a bypass let raw error text through'); + assert.match(result.content, /\[masked:email\]/); + assert.deepEqual(receipts, []); + }); + + it('honours the intern EXEMPTION for an error, exactly as for a result', async () => { + // A self/infra tool's failure IS the agent's own operational state — the + // case the allowlist exists for. (Such tools are unreachable from the public + // endpoint anyway; `isPubliclyServableTool` filters them at the allowlist.) + const service = new ToolDispatchService({ + nativeTools: throwingRegistryWith('memory', PII_ERROR), + domainTools: [], + privacy: () => redactingPrivacyHandle(), + }); + + const result = await service.dispatch('memory', {}); + + assert.equal(result.content, PII_ERROR); + assert.equal(result.isError, true); + }); + + it('leaves the error message UNCHANGED when no privacy provider is installed', async () => { + // Parity with `afterDispatch`. The public endpoint refuses to call at all in + // this configuration (`requirePrivacyMasking`), so this is the loopback/CLI + // case, where the reader is the local operator. + const service = new ToolDispatchService({ + nativeTools: throwingRegistryWith('odoo_search_partner', PII_ERROR), + domainTools: [], + }); + + const result = await service.dispatch('odoo_search_partner', {}); + + assert.equal(result.content, PII_ERROR); + assert.equal(result.isError, true); + }); + + it('falls back to the raw message when masking itself throws — and still flags the error', async () => { + // Documented fail-OPEN, safe ONLY because `publicMcpPrivacy.ts`'s gate never + // lets `internToolResultV4` throw and `PublicMcpServer` refuses an unmasked + // result. Asserted so the branch cannot change silently. + const service = new ToolDispatchService({ + nativeTools: throwingRegistryWith('odoo_search_partner', PII_ERROR), + domainTools: [], + privacy: () => redactingPrivacyHandle({ internThrows: true }), + }); + + const result = await service.dispatch('odoo_search_partner', {}); + + assert.equal(result.content, PII_ERROR); + assert.equal(result.isError, true); + }); + + it('masks a non-Error throw (a bare string) too — `errMsg` stringifies, it does not sanitize', async () => { + const nativeTools = new NativeToolRegistry(); + nativeTools.register('odoo_search_partner', { + handler: () => { + // Deliberately not an Error: `errMsg` falls back to `String(error)`, + // which stringifies without sanitizing anything. + throw PII_ERROR; + }, + spec: { + name: 'odoo_search_partner', + description: 'throws a bare string', + input_schema: { type: 'object', properties: {} }, + }, + domain: 'test.pii', + }); + const service = new ToolDispatchService({ + nativeTools, + domainTools: [], + privacy: () => redactingPrivacyHandle(), + }); + + const result = await service.dispatch('odoo_search_partner', {}); + + assert.equal(result.content.includes(EMAIL), false); + assert.match(result.content, /\[masked:email\]/); + }); +}); + +describe('ToolDispatchService — raw-result capture (#542 prerequisite)', () => { + it('captures the RAW result before masking, while the caller gets the MASKED one', async () => { + const captured: Array<{ name: string; result: string; caller?: ToolDispatchCallerContext }> = []; + const service = new ToolDispatchService({ + nativeTools: registryWith('odoo_read_partner', PII_RESULT), + domainTools: [], + privacy: () => redactingPrivacyHandle(), + captureRawToolResult: (name, result, caller) => { + captured.push({ name, result, ...(caller !== undefined ? { caller } : {}) }); + }, + }); + + const result = await service.dispatch('odoo_read_partner', {}); + + // The trace consumer sees ground truth … + assert.equal(captured.length, 1); + assert.equal(captured[0]?.result, PII_RESULT); + // … and the caller does NOT. Both halves matter: capturing the masked value + // would make traces useless, returning the raw value would be the leak. + assert.equal(result.content.includes(EMAIL), false); + }); + + it('survives a throwing capture callback without failing the tool call', async () => { + const service = new ToolDispatchService({ + nativeTools: registryWith('odoo_read_partner', PII_RESULT), + domainTools: [], + captureRawToolResult: () => { + throw new Error('audit sink exploded'); + }, + }); + + const result = await service.dispatch('odoo_read_partner', {}); + + assert.equal(result.content, PII_RESULT); + assert.equal(result.isError, undefined); + }); +}); + +describe('ToolDispatchService — caller context seam (#542 prerequisite)', () => { + it('propagates the caller identity to layers BENEATH the handler', async () => { + const nativeTools = new NativeToolRegistry(); + let seenInsideHandler: ToolDispatchCallerContext | undefined; + nativeTools.register('whoami', { + // A plugin handler cannot receive identity as a parameter — the + // `NativeToolHandler` contract is published — so it must be readable + // ambiently, which is what this asserts. + handler: async () => { + seenInsideHandler = currentDispatchCaller(); + return 'ok'; + }, + spec: { + name: 'whoami', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + domain: 'test.x', + }); + const service = new ToolDispatchService({ nativeTools, domainTools: [] }); + + const caller: ToolDispatchCallerContext = { + principal: 'apikey_123', + scopes: ['tools:write'], + tenantId: 'tenant-a', + userId: 'user-7', + requestId: 'req-abc', + }; + await service.dispatch('whoami', {}, { caller }); + + assert.deepEqual(seenInsideHandler, caller); + }); + + it('leaves the ambient caller EMPTY on the loopback path (no caller supplied)', async () => { + const nativeTools = new NativeToolRegistry(); + let seenInsideHandler: ToolDispatchCallerContext | undefined = { + principal: 'sentinel', + }; + nativeTools.register('whoami', { + handler: async () => { + seenInsideHandler = currentDispatchCaller(); + return 'ok'; + }, + spec: { + name: 'whoami', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + domain: 'test.x', + }); + const service = new ToolDispatchService({ nativeTools, domainTools: [] }); + + await service.dispatch('whoami', {}); + + assert.equal(seenInsideHandler, undefined); + }); +}); diff --git a/middleware/test/cliBridge/toolDispatchService.test.ts b/middleware/test/cliBridge/toolDispatchService.test.ts index 25345f98..b53f377c 100644 --- a/middleware/test/cliBridge/toolDispatchService.test.ts +++ b/middleware/test/cliBridge/toolDispatchService.test.ts @@ -181,13 +181,19 @@ describe('ToolDispatchService', () => { }); const specs = service.listDispatchableToolSpecs(); + // W0-3 — advertised name-sorted (this used to be registration order: + // natives in Map order, then domain tools). Order is the only thing the + // sort changed; the precedence assertion below is unchanged. assert.deepEqual( specs.map((spec) => spec.name), - ['echo_native', 'shared_name', 'domain_ping'], + ['domain_ping', 'echo_native', 'shared_name'], ); - assert.equal(specs[0]?.input_schema.type, 'object'); - assert.equal(specs[2]?.input_schema.type, 'object'); - assert.equal(specs[1]?.description, 'native shared'); + // Look specs up by name so this stays honest if the ordering ever moves. + const byName = new Map(specs.map((spec) => [spec.name, spec])); + assert.equal(byName.get('echo_native')?.input_schema.type, 'object'); + assert.equal(byName.get('domain_ping')?.input_schema.type, 'object'); + // Native still wins the `shared_name` collision. + assert.equal(byName.get('shared_name')?.description, 'native shared'); }); it('issue #474: refuses to dispatch a not-ready plugin tool and excludes it from the list', async () => { diff --git a/middleware/test/devplatform/daemonProtocol.test.ts b/middleware/test/devplatform/daemonProtocol.test.ts index c6e6c3df..15a7bbe5 100644 Binary files a/middleware/test/devplatform/daemonProtocol.test.ts and b/middleware/test/devplatform/daemonProtocol.test.ts differ diff --git a/middleware/test/devplatform/devJobTaskStore.pg.test.ts b/middleware/test/devplatform/devJobTaskStore.pg.test.ts new file mode 100644 index 00000000..a10f06cf --- /dev/null +++ b/middleware/test/devplatform/devJobTaskStore.pg.test.ts @@ -0,0 +1,424 @@ +import { strict as assert } from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { after, before, describe, it } from 'node:test'; + +import { Pool } from 'pg'; + +import { TaskLeaseLostError, runMultiOrchestratorMigrations } from '@omadia/orchestrator'; + +import { DevJobEventBus } from '../../src/devplatform/devJobEventBus.js'; +import { + DevJobStore, + TERMINAL_FINISH_BRAND, + type DevJobSweepScope, +} from '../../src/devplatform/devJobStore.js'; +import { + createDevJobTaskStore, + projectDevJobStatus, + toTaskDescriptor, +} from '../../src/devplatform/devJobTaskStore.js'; +import { DevRepoStore } from '../../src/devplatform/devRepoStore.js'; +import { mintRunnerToken } from '../../src/devplatform/jobToken.js'; +import { DEV_JOB_STATUSES } from '../../src/devplatform/types.js'; +import type { DevJob, DevJobStatus, DevRepo } from '../../src/devplatform/types.js'; + +/** + * W2-2 — SEAM CONFORMANCE: `dev_job` driven through the generic `TaskStore` + * interface against real Postgres. + * + * The point is not "the adapter compiles" — it is that dev_job's REAL + * claim/lease and terminal-transition behaviour is what the seam's contract + * describes. So these assertions deliberately mirror the ones + * `devJobStore.pg.test.ts` already makes on the same operations, reached through + * the seam instead of directly. + * + * TENANT ISOLATION: `MARK` carries a fresh UUID per run, so a concurrently + * running dev-platform pg suite in the same cluster cannot see or delete these + * rows (the migration advisory lock is database-wide, and `CREATE/DROP DATABASE` + * is cluster-wide — hence neither is used here). + */ +const PG_URL = + process.env['GRAPH_PG_TEST_URL'] ?? + process.env['MEMORY_PG_TEST_URL'] ?? + process.env['WS5_PG_TEST_URL'] ?? + process.env['DATABASE_URL'] ?? + 'postgres://test:test@127.0.0.1:55438/test'; + +/** Unique per run — this is the tenant id. */ +const MARK = `pg-task-seam-${randomUUID()}`; +const migrationsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'migrations'); + +const probePool = new Pool({ connectionString: PG_URL, connectionTimeoutMillis: 2000 }); +let pgAvailable = true; +try { + await probePool.query('SELECT 1'); +} catch { + pgAvailable = false; + await probePool.end().catch(() => undefined); +} + +describe('devplatform/devJobTaskStore — seam conformance (pg)', { skip: !pgAvailable }, () => { + const pool = probePool; + const jobStore = new DevJobStore(pool, { eventBus: new DevJobEventBus() }); + const repoStore = new DevRepoStore(pool); + let repo: DevRepo; + + /** Mirrors `finalizeDevJob`'s terminal write; the seam's `finish` is bound to + * this so the brand-gated choke point is exercised, not bypassed. */ + async function finalize( + jobId: string, + status: DevJobStatus, + patch: { error?: string }, + ): Promise { + return jobStore.finishTerminal(TERMINAL_FINISH_BRAND, jobId, status, patch); + } + + function seam( + overrides: { + createJob?: (input: unknown) => Promise | Promise; + /** W3-A — constrains `reapOrphans`' sweep to this run's repos. */ + sweepScope?: DevJobSweepScope; + purgeTerminalJobs?: (olderThanDays: number, now: Date) => Promise; + } = {}, + ) { + return createDevJobTaskStore({ + jobStore, + createJob: + overrides.createJob ?? + (async () => { + throw new Error('createJob not wired in this test'); + }), + finalize, + ...(overrides.sweepScope ? { sweepScope: overrides.sweepScope } : {}), + ...(overrides.purgeTerminalJobs + ? { purgeTerminalJobs: overrides.purgeTerminalJobs } + : {}), + }); + } + + async function cleanup(): Promise { + // Cascades to dev_jobs → dev_job_events → dev_job_artifacts. Scoped to THIS + // run's tenant id, so a parallel suite's rows are never touched. + await pool.query('DELETE FROM dev_repos WHERE created_by = $1', [MARK]); + } + + async function newRepo(): Promise { + return repoStore.createRepo({ + owner: MARK, + name: `r-${randomUUID().slice(0, 8)}`, + cloneUrl: 'https://example.com/x/y.git', + credentialKind: 'pat', + credentialRef: 'repo/x', + createdBy: MARK, + }); + } + + async function newQueuedJob(repoId: string): Promise { + const { hash } = mintRunnerToken(); + return jobStore.createJob({ + repoId, + kind: 'implement', + brief: 'b', + source: 'admin', + backend: 'local', + createdBy: MARK, + runnerTokenHash: hash, + }); + } + + before(async () => { + await runMultiOrchestratorMigrations(pool, undefined, migrationsDir); + await cleanup(); + repo = await newRepo(); + }); + + after(async () => { + await cleanup(); + await pool.end(); + }); + + it('projects every DevJobStatus onto the four-value seam vocabulary', () => { + // Exhaustive: a status added to the union without a projection is a build + // error in the adapter, and this pins that none maps to undefined at runtime. + for (const s of DEV_JOB_STATUSES) { + const projected = projectDevJobStatus(s); + assert.ok( + ['working', 'input_required', 'completed', 'failed'].includes(projected), + `${s} projected to ${String(projected)}`, + ); + } + assert.equal(projectDevJobStatus('queued'), 'working'); + assert.equal(projectDevJobStatus('running'), 'working'); + assert.equal(projectDevJobStatus('waiting'), 'input_required'); + assert.equal(projectDevJobStatus('done'), 'completed'); + // A cancel did not produce the result the caller asked for, so reporting it + // as `completed` would make a poll claim an answer that does not exist. + assert.equal(projectDevJobStatus('cancelled'), 'failed'); + assert.equal(projectDevJobStatus('budget_exceeded'), 'failed'); + }); + + it('reads a real job through the seam with the projection applied', async () => { + const job = await newQueuedJob(repo.id); + const store = seam(); + const d = await store.get(job.id); + assert.ok(d); + assert.equal(d.id, job.id); + assert.equal(d.status, 'working', 'queued projects to working'); + assert.equal(d.phase, job.phase, 'the pipeline phase survives the projection'); + assert.equal(d.claimedBy, null); + assert.equal(d.endedAt, null); + assert.deepEqual(d, toTaskDescriptor(job)); + }); + + it('get() on an unknown id is null, not a throw', async () => { + assert.equal(await seam().get(randomUUID()), null); + }); + + it('claimNextPending stamps a real lease via FOR UPDATE SKIP LOCKED', async () => { + const localRepo = await newRepo(); + const job = await newQueuedJob(localRepo.id); + const store = seam(); + const lease = randomUUID(); + + const claimed = await store.claimNextPending(lease); + assert.ok(claimed); + // The claim is dev_job's own: status went queued → provisioning, which still + // projects to `working`, and the lease is on the row in Postgres. + assert.equal(claimed.descriptor.status, 'working'); + assert.equal(claimed.descriptor.claimedBy, lease); + const row = await jobStore.getJob(claimed.descriptor.id); + assert.equal(row?.claimedBy, lease); + assert.equal(row?.status, 'provisioning'); + void job; + }); + + it('rejects a non-UUID lease before it reaches the uuid column', async () => { + await assert.rejects(() => seam().claimNextPending('nope'), TypeError); + }); + + it('two concurrent claims through the seam never return the same job', async () => { + const localRepo = await newRepo(); + await newQueuedJob(localRepo.id); + await newQueuedJob(localRepo.id); + const store = seam(); + + const [a, b] = await Promise.all([ + store.claimNextPending(randomUUID()), + store.claimNextPending(randomUUID()), + ]); + assert.ok(a && b, 'both claimers got a job (two were queued)'); + assert.notEqual(a.descriptor.id, b.descriptor.id, 'disjoint jobs'); + }); + + it('FENCES a mismatched lease — the property the fence exists for', async () => { + const localRepo = await newRepo(); + await newQueuedJob(localRepo.id); + const store = seam(); + const good = randomUUID(); + const claimed = await store.claimNextPending(good); + assert.ok(claimed); + const id = claimed.descriptor.id; + const stale = randomUUID(); + + await assert.rejects(() => store.heartbeat(id, stale), TaskLeaseLostError); + await assert.rejects( + () => store.appendEvents(id, stale, [{ type: 'log', message: 'hijack' }]), + TaskLeaseLostError, + ); + await assert.rejects( + () => store.finish(id, stale, { status: 'completed' }), + TaskLeaseLostError, + ); + + // Nothing the stale lease attempted landed in Postgres. + const row = await jobStore.getJob(id); + assert.equal(row?.status, 'provisioning', 'still live, not finalized'); + assert.equal(row?.claimedBy, good, 'the real owner still holds the lease'); + assert.deepEqual(await store.eventTail(id, 10), []); + }); + + it('accepts an UNLEASED administrative finalize (cancel route / reaper)', async () => { + // dev_job's finishTerminal is deliberately unfenced for exactly this case. + // The adapter must not break it while still rejecting a mismatched lease. + const localRepo = await newRepo(); + const job = await newQueuedJob(localRepo.id); + assert.equal(job.claimedBy, null, 'never claimed'); + const store = seam(); + + const done = await store.finish(job.id, randomUUID(), { + status: 'failed', + error: 'cancelled by operator', + }); + assert.equal(done.status, 'failed'); + assert.equal(done.error, 'cancelled by operator'); + assert.equal((await jobStore.getJob(job.id))?.status, 'failed'); + }); + + it('finish routes through the brand-gated terminal write', async () => { + const localRepo = await newRepo(); + await newQueuedJob(localRepo.id); + const store = seam(); + const lease = randomUUID(); + const claimed = await store.claimNextPending(lease); + assert.ok(claimed); + + const done = await store.finish(claimed.descriptor.id, lease, { status: 'completed' }); + assert.equal(done.status, 'completed', 'dev_job `done` projects to completed'); + assert.ok(done.endedAt !== null); + const row = await jobStore.getJob(claimed.descriptor.id); + assert.equal(row?.status, 'done', 'the underlying dev_job status is `done`'); + assert.ok(row?.endedAt !== null); + }); + + it('a terminal job refuses further seam writes', async () => { + const localRepo = await newRepo(); + await newQueuedJob(localRepo.id); + const store = seam(); + const lease = randomUUID(); + const claimed = await store.claimNextPending(lease); + assert.ok(claimed); + const id = claimed.descriptor.id; + await store.finish(id, lease, { status: 'completed' }); + + // `finishTerminal` is idempotent (0 rows, returns existing state), so the + // outcome must be unchanged rather than flipped. + const again = await store.finish(id, lease, { status: 'failed', error: 'nope' }); + assert.equal(again.status, 'completed', 'the recorded outcome is immutable'); + // A heartbeat on a terminal job touches 0 rows and must be reported as lost. + await assert.rejects(() => store.heartbeat(id, lease), TaskLeaseLostError); + }); + + it('surfaces a real event tail through the seam', async () => { + const localRepo = await newRepo(); + await newQueuedJob(localRepo.id); + const store = seam(); + const lease = randomUUID(); + const claimed = await store.claimNextPending(lease); + assert.ok(claimed); + const id = claimed.descriptor.id; + + await store.appendEvents(id, lease, [ + { type: 'log', message: 'first' }, + { type: 'status', message: 'second' }, + ]); + const tail = await store.eventTail(id, 5); + assert.equal(tail.length, 2); + assert.deepEqual(tail.map((e) => e.message), ['first', 'second']); + // `seq` is the monotonic IDENTITY column, not dev_job's per-provision seq + // (which restarts and would go backwards across a re-provision). + assert.ok(tail[1] !== undefined && tail[0] !== undefined); + assert.ok(tail[1].seq > tail[0].seq, 'seq is monotonic across the tail'); + }); + + it('list() applies the LIMIT after projection, not before', async () => { + // A `status: 'working'` filter must not silently return fewer live jobs + // because terminal rows consumed the SQL LIMIT first. + const localRepo = await newRepo(); + const live = await newQueuedJob(localRepo.id); + const store = seam(); + const working = await store.list({ status: 'working' }); + assert.ok( + working.some((d) => d.id === live.id), + 'the live job appears under the projected status', + ); + assert.ok(working.every((d) => d.status === 'working')); + }); + + // ── reapOrphans, at the pg level (W3-A) ─────────────────────────────────── + // + // This used to be deliberately absent. `DevJobStore.findStalled` was + // database-global with no scope predicate, so a forward-dated cutoff here + // finalized OTHER suites' in-flight jobs as `stalled` — it broke + // `devPlatformPipeline.wire.pg.test.ts` the first time it ran. Global IS the + // correct production behaviour, so the fix was to make the sweep NARROWABLE + // (`DevJobSweepScope`) rather than to change what production does. With the + // scope bound to this run's own repos the sweep is finally testable against + // real rows, so the fake-`findStalled` unit test in + // `test/tasks/devJobTaskStoreReap.test.ts` is no longer the only coverage. + + /** + * A job in the ACTIVE set (`provisioning`) on a KNOWN repo. + * + * NOT via `claimNextQueued`: that pops the oldest queued row DATABASE-WIDE, so + * with concurrent dev-platform pg suites (and this suite's own earlier jobs) it + * cannot be aimed at a repo. The reaper only reads `status` + the heartbeat + * columns, so stamping them is a faithful and deterministic setup. + */ + async function newActiveJob(repoId: string): Promise { + const job = await newQueuedJob(repoId); + await pool.query( + `UPDATE dev_jobs + SET status = 'provisioning', claimed_by = $2, claimed_at = now(), started_at = now() + WHERE id = $1`, + [job.id, randomUUID()], + ); + const active = await jobStore.getJob(job.id); + assert.ok(active && active.status === 'provisioning', 'setup failed to activate the job'); + return active; + } + + it('MUTATION CHECK: reapOrphans finalizes a real stalled job as `stalled`, scoped to its own repos', async () => { + const localRepo = await newRepo(); + const otherRepo = await newRepo(); + const inScope = await newActiveJob(localRepo.id); + const outOfScope = await newActiveJob(otherRepo.id); + + const store = seam({ sweepScope: { repoIds: [localRepo.id] } }); + // Forward-dated `now` ⇒ every active job is past the cutoff. Before the scope + // predicate existed this is precisely what reached across suites. + const result = await store.reapOrphans({ + now: new Date(Date.now() + 3_600_000), + staleAfterMs: 1_000, + purgeTerminalAfterMs: 30 * 86_400_000, + }); + + assert.equal(result.staleFailed, 1, 'exactly the in-scope job was reaped'); + const reaped = await jobStore.getJob(inScope.id); + assert.equal(reaped?.status, 'stalled'); + assert.match(String(reaped?.error), /no worker heartbeat/); + // The load-bearing half: the sibling repo's in-flight job is UNTOUCHED. This + // is the assertion whose absence forced the whole test to be downgraded. + const untouched = await jobStore.getJob(outOfScope.id); + assert.equal(untouched?.status, 'provisioning', 'the sweep escaped its scope'); + assert.equal(untouched?.error, null); + }); + + it('MUTATION CHECK: an EMPTY scope reaps nothing — it must never widen to global', async () => { + const localRepo = await newRepo(); + const active = await newActiveJob(localRepo.id); + + const result = await seam({ sweepScope: { repoIds: [] } }).reapOrphans({ + now: new Date(Date.now() + 3_600_000), + staleAfterMs: 1_000, + purgeTerminalAfterMs: 30 * 86_400_000, + }); + + assert.equal(result.staleFailed, 0, 'an empty scope swept something'); + // A caller who computed an empty entitlement set must not become a caller who + // sweeps everything — `= ANY('{}')` semantics, made explicit. + assert.equal((await jobStore.getJob(active.id))?.status, 'provisioning'); + }); + + it('findStalled without a scope stays DATABASE-GLOBAL (production behaviour)', async () => { + // Production passes no scope and must keep reaching every abandoned job. Both + // repos' active jobs are visible to an unscoped sweep. + const a = await newRepo(); + const b = await newRepo(); + const one = await newActiveJob(a.id); + const two = await newActiveJob(b.id); + + const global = await jobStore.findStalled(new Date(Date.now() + 3_600_000)); + const ids = new Set(global.map((j) => j.id)); + assert.ok(ids.has(one.id) && ids.has(two.id), 'the unscoped sweep lost a repo'); + // …and the scoped call over the same cutoff sees only its own. + const scoped = await jobStore.findStalled(new Date(Date.now() + 3_600_000), { + repoIds: [a.id], + }); + assert.deepEqual( + scoped.map((j) => j.repoId), + [a.id], + ); + }); +}); diff --git a/middleware/test/devplatform/devJobTaskStoreReap.test.ts b/middleware/test/devplatform/devJobTaskStoreReap.test.ts new file mode 100644 index 00000000..60e20f35 --- /dev/null +++ b/middleware/test/devplatform/devJobTaskStoreReap.test.ts @@ -0,0 +1,254 @@ +import { strict as assert } from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { describe, it } from 'node:test'; + +import { TaskLeaseLostError } from '@omadia/orchestrator'; + +import { + createDevJobTaskStore, + projectDevJobStatus, + type DevJobTaskJobStore, +} from '../../src/devplatform/devJobTaskStore.js'; +import type { DevJob, DevJobStatus } from '../../src/devplatform/types.js'; + +/** + * W2-2 — the dev_job adapter's orphan sweep, isolated. + * + * W3-A UPDATE: the pg-level sweep is now covered too — + * `test/devplatform/devJobTaskStore.pg.test.ts` drives `reapOrphans` against real + * rows with `DevJobSweepScope` bound to its own repos. This file keeps the + * driven-clock and error-shape coverage the fake makes cheap. Historical note: + * + * Why this was not in the pg suite: `DevJobStore.findStalled` was DATABASE-GLOBAL (no + * tenant predicate), so triggering a real sweep in a shared test cluster + * finalizes other suites' in-flight jobs as `stalled`. That is correct + * production behaviour and an untenable test, so the sweep is driven here + * against a controlled `findStalled`. + */ + +function job(overrides: Partial = {}): DevJob { + return { + id: randomUUID(), + repoId: 'repo-1', + kind: 'implement', + brief: 'b', + source: 'admin', + sourceRef: null, + baseSha: null, + backend: 'local', + agentKind: 'claude-cli', + authMode: 'api_key', + provision: 1, + phase: 'analyze', + pipelineMode: 'gated', + reviewAttempt: 0, + reviewFingerprint: null, + retryOf: null, + status: 'running', + claimedBy: null, + claimedAt: null, + lastHeartbeatAt: null, + runnerHandle: null, + runnerTokenHash: null, + branch: null, + prUrl: null, + result: null, + error: null, + tokensIn: 0, + tokensOut: 0, + costUsd: 0, + budgetCostUsd: null, + budgetTokens: null, + usageEstimated: false, + createdBy: 'test', + createdAt: new Date(0).toISOString(), + startedAt: null, + endedAt: null, + updatedAt: new Date(0).toISOString(), + ...overrides, + } as DevJob; +} + +interface Harness { + readonly store: ReturnType; + readonly finalizeCalls: { jobId: string; status: DevJobStatus; error?: string }[]; + readonly stalledCutoffs: Date[]; + readonly purgeCalls: { days: number; now: Date }[]; +} + +function harness(opts: { + stalled?: DevJob[]; + jobs?: DevJob[]; + purged?: number; + finalizeReturnsNull?: boolean; +}): Harness { + const finalizeCalls: Harness['finalizeCalls'] = []; + const stalledCutoffs: Date[] = []; + const purgeCalls: Harness['purgeCalls'] = []; + const byId = new Map((opts.jobs ?? []).map((j) => [j.id, j])); + + const jobStore: DevJobTaskJobStore = { + createJob: async () => { + throw new Error('unused'); + }, + getJob: async (id) => byId.get(id) ?? null, + listJobs: async () => [...byId.values()], + claimNextQueued: async () => null, + touchHeartbeat: async () => true, + appendEvents: async () => 0, + findStalled: async (cutoff) => { + stalledCutoffs.push(cutoff); + return opts.stalled ?? []; + }, + }; + + const store = createDevJobTaskStore({ + jobStore, + createJob: async () => { + throw new Error('unused'); + }, + finalize: async (jobId, status, patch) => { + finalizeCalls.push({ + jobId, + status, + ...(patch.error !== undefined ? { error: patch.error } : {}), + }); + if (opts.finalizeReturnsNull) return null; + const existing = byId.get(jobId) ?? job({ id: jobId }); + const updated = job({ ...existing, status, error: patch.error ?? null }); + byId.set(jobId, updated); + return updated; + }, + purgeTerminalJobs: async (days, now) => { + purgeCalls.push({ days, now }); + return opts.purged ?? 0; + }, + }); + + return { store, finalizeCalls, stalledCutoffs, purgeCalls }; +} + +describe('devplatform/devJobTaskStore — orphan sweep', () => { + it('finalizes every stalled job as dev_job `stalled` with an abandoned reason', async () => { + const a = job(); + const b = job(); + const h = harness({ stalled: [a, b], jobs: [a, b] }); + + const r = await h.store.reapOrphans({ + now: new Date(1_000_000), + staleAfterMs: 300_000, + purgeTerminalAfterMs: 3_600_000, + }); + + assert.equal(r.staleFailed, 2); + assert.deepEqual( + h.finalizeCalls.map((c) => c.jobId).sort(), + [a.id, b.id].sort(), + ); + for (const call of h.finalizeCalls) { + assert.equal(call.status, 'stalled'); + assert.match(String(call.error), /abandoned/); + } + // `stalled` is what dev_job records; the seam reports the projection. + assert.equal(projectDevJobStatus('stalled'), 'failed'); + }); + + it('derives the stale cutoff from now minus the window', async () => { + const h = harness({}); + await h.store.reapOrphans({ + now: new Date(1_000_000), + staleAfterMs: 300_000, + purgeTerminalAfterMs: 3_600_000, + }); + assert.equal(h.stalledCutoffs.length, 1); + assert.equal(h.stalledCutoffs[0]?.getTime(), 700_000); + }); + + it('does not count a job the terminal write refused', async () => { + // `finishTerminal` returns the existing row (or null) when the job is + // already terminal or absent — a double sweep must not inflate the count. + const a = job(); + const h = harness({ stalled: [a], jobs: [a], finalizeReturnsNull: true }); + const r = await h.store.reapOrphans({ + now: new Date(1_000_000), + staleAfterMs: 1, + purgeTerminalAfterMs: 1, + }); + assert.equal(r.staleFailed, 0, 'a refused finalize is not a reap'); + assert.equal(h.finalizeCalls.length, 1, 'but it WAS attempted'); + }); + + it('converts the retain window to whole days, rounding UP', async () => { + // `purgeTerminalJobs` takes days; rounding DOWN would produce 0 and throw, + // and would also purge more aggressively than the caller asked for. + const h = harness({ purged: 4 }); + const r = await h.store.reapOrphans({ + now: new Date(0), + staleAfterMs: 1, + // 1.5 days + purgeTerminalAfterMs: 129_600_000, + }); + assert.equal(r.purged, 4); + assert.equal(h.purgeCalls[0]?.days, 2, '1.5 days rounds up to 2'); + }); + + it('never asks for a zero-day purge window', async () => { + const h = harness({ purged: 0 }); + await h.store.reapOrphans({ + now: new Date(0), + staleAfterMs: 1, + purgeTerminalAfterMs: 1, // sub-day + }); + assert.equal(h.purgeCalls[0]?.days, 1, 'clamped to a minimum of 1 day'); + }); + + it('reports 0 purged when no purge hook is wired', async () => { + const store = createDevJobTaskStore({ + jobStore: { + createJob: async () => { + throw new Error('unused'); + }, + getJob: async () => null, + listJobs: async () => [], + claimNextQueued: async () => null, + touchHeartbeat: async () => true, + appendEvents: async () => 0, + findStalled: async () => [], + }, + createJob: async () => { + throw new Error('unused'); + }, + finalize: async () => null, + }); + const r = await store.reapOrphans({ + staleAfterMs: 1, + purgeTerminalAfterMs: 1, + }); + assert.deepEqual(r, { staleFailed: 0, purged: 0 }); + }); +}); + +describe('devplatform/devJobTaskStore — fence on a missing job', () => { + it('treats an absent job as a lost lease, not a crash', async () => { + const h = harness({}); + await assert.rejects( + () => h.store.heartbeat(randomUUID(), randomUUID()), + TaskLeaseLostError, + ); + }); + + it('setPhase is a documented no-op after the fence', async () => { + // dev_job's phase machine is `advancePhase(from, to)`, fenced on the phase + // being LEFT; a generic `setPhase(to)` cannot express that without racing. + // So the seam checks the fence and declines to drive the phase. + const a = job({ claimedBy: 'lease-1' }); + const h = harness({ jobs: [a] }); + await h.store.setPhase(a.id, 'lease-1', 'implement'); + assert.equal(a.phase, 'analyze', 'the phase is untouched'); + await assert.rejects( + () => h.store.setPhase(a.id, 'other-lease', 'implement'), + TaskLeaseLostError, + 'but the fence still applies', + ); + }); +}); diff --git a/middleware/test/embeddingGateWriteFence.pg.test.ts b/middleware/test/embeddingGateWriteFence.pg.test.ts index 780f9705..ac95364e 100644 --- a/middleware/test/embeddingGateWriteFence.pg.test.ts +++ b/middleware/test/embeddingGateWriteFence.pg.test.ts @@ -160,11 +160,25 @@ describe('#440 gate-epoch write fence (real Postgres, real backfill handle)', { pendingTurns?: number; pendingProcesses?: number; }): Promise { - await real.query( - 'DROP TABLE IF EXISTS graph_nodes, processes, process_history, graph_embedding_model', - ); + // Recreate the SCHEMA, and qualify every name below — never `DROP TABLE IF + // EXISTS graph_nodes` unqualified. + // + // A DROP resolves an unqualified name THROUGH the search_path. On the first + // test this suite's own schema is still empty, so `graph_nodes` fell through + // to `public.graph_nodes` — a table the real KG suites create and which + // survives in the container between runs. That is why this suite passed on a + // pristine database and then failed on EVERY subsequent with-pg run: the + // second run's DROP hit `public.graph_nodes` and errored 2BP01 on + // `public.graph_edges`' foreign keys, so the CREATE never ran, so the next + // test's DROP fell through again — self-perpetuating, all six tests. + // + // The FK is the only reason this surfaced as a loud error. `public.processes` + // and `public.graph_embedding_model` have no dependents, so the same + // fall-through was SILENTLY DROPPING a sibling suite's tables. + await real.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`); + await real.query(`CREATE SCHEMA ${SCHEMA}`); await real.query(` - CREATE TABLE graph_nodes ( + CREATE TABLE ${SCHEMA}.graph_nodes ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, type TEXT NOT NULL, @@ -177,7 +191,7 @@ describe('#440 gate-epoch write fence (real Postgres, real backfill handle)', { created_at TIMESTAMPTZ NOT NULL DEFAULT now() )`); await real.query(` - CREATE TABLE processes ( + CREATE TABLE ${SCHEMA}.processes ( id TEXT NOT NULL, tenant_id TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'team', @@ -191,7 +205,7 @@ describe('#440 gate-epoch write fence (real Postgres, real backfill handle)', { PRIMARY KEY (tenant_id, id) )`); await real.query(` - CREATE TABLE process_history ( + CREATE TABLE ${SCHEMA}.process_history ( id TEXT NOT NULL, tenant_id TEXT NOT NULL, version INTEGER NOT NULL, @@ -201,7 +215,7 @@ describe('#440 gate-epoch write fence (real Postgres, real backfill handle)', { superseded_at TIMESTAMPTZ NOT NULL )`); await real.query(` - CREATE TABLE graph_embedding_model ( + CREATE TABLE ${SCHEMA}.graph_embedding_model ( tenant_id TEXT PRIMARY KEY, model_id TEXT NOT NULL, dimensions INTEGER NOT NULL, @@ -211,7 +225,7 @@ describe('#440 gate-epoch write fence (real Postgres, real backfill handle)', { )`); for (let i = 0; i < (opts.pendingTurns ?? 0); i++) { await real.query( - `INSERT INTO graph_nodes (id, tenant_id, type, external_id, properties) + `INSERT INTO ${SCHEMA}.graph_nodes (id, tenant_id, type, external_id, properties) VALUES ($1, $2, 'Turn', $3, $4::jsonb)`, [ `00000000-0000-4000-8000-00000000000${String(i)}`, @@ -223,34 +237,55 @@ describe('#440 gate-epoch write fence (real Postgres, real backfill handle)', { } for (let i = 0; i < (opts.pendingProcesses ?? 0); i++) { await real.query( - `INSERT INTO processes (id, tenant_id, title, steps) + `INSERT INTO ${SCHEMA}.processes (id, tenant_id, title, steps) VALUES ($1, $2, $3, $4::jsonb)`, [`proc:${String(i)}`, TENANT, `Backend: step ${String(i)}`, JSON.stringify(['do it'])], ); } // Three days old: the switch cooldown guards a rolling deploy, not a test. await real.query( - `INSERT INTO graph_embedding_model (tenant_id, model_id, dimensions, updated_at) + `INSERT INTO ${SCHEMA}.graph_embedding_model (tenant_id, model_id, dimensions, updated_at) VALUES ($1, 'ollama:nomic-embed-text', 768, now() - interval '3 days')`, [TENANT], ); + await assertFixtureIsIsolated(); } + // Assertions read SCHEMA-QUALIFIED names for the same reason the DDL writes + // them: an unqualified read that fell through to `public` would not error, it + // would quietly assert against a sibling suite's rows. const storedVectors = async (table: string): Promise => { const r = await real.query<{ v: string }>( - `SELECT embedding::text AS v FROM ${table} WHERE embedding IS NOT NULL`, + `SELECT embedding::text AS v FROM ${SCHEMA}.${table} WHERE embedding IS NOT NULL`, ); return r.rows.map((row) => row.v); }; const attemptCounters = async (): Promise => { const r = await real.query<{ n: number }>( - 'SELECT embedding_attempts AS n FROM graph_nodes WHERE tenant_id = $1', + `SELECT embedding_attempts AS n FROM ${SCHEMA}.graph_nodes WHERE tenant_id = $1`, [TENANT], ); return r.rows.map((row) => Number(row.n)); }; + /** + * Guard for the fixture itself: every table this suite drives must live in + * THIS suite's schema. Cheap, and it turns a silent cross-suite collision + * (the fall-through above) into an immediate, named failure. + */ + async function assertFixtureIsIsolated(): Promise { + const r = await real.query<{ tablename: string }>( + `SELECT tablename FROM pg_tables WHERE schemaname = $1 ORDER BY tablename`, + [SCHEMA], + ); + assert.deepEqual( + r.rows.map((row) => row.tablename), + ['graph_embedding_model', 'graph_nodes', 'process_history', 'processes'], + `the fixture did not land in ${SCHEMA} — it resolved through the search_path`, + ); + } + /** * The plugin's own wiring, verbatim in shape: ONE `syncBackfill` that stops * the outgoing handle and constructs a real replacement, handed to @@ -452,7 +487,7 @@ describe('#440 gate-epoch write fence (real Postgres, real backfill handle)', { // embedder for MemorableKnowledge / PalaiaExcerpt. Same shape, same window. await freshSchema({}); await real.query( - `INSERT INTO graph_nodes (id, tenant_id, type, external_id) + `INSERT INTO ${SCHEMA}.graph_nodes (id, tenant_id, type, external_id) VALUES ('11111111-0000-4000-8000-000000000000', $1, 'MemorableKnowledge', 'mk:1')`, [TENANT], ); @@ -598,7 +633,7 @@ describe('#440 gate-epoch write fence (real Postgres, real backfill handle)', { // it; the ROLLBACK makes the fenced write a clean no-op on both counts. await freshSchema({}); await real.query( - `INSERT INTO processes (id, tenant_id, title, steps, embedding) + `INSERT INTO ${SCHEMA}.processes (id, tenant_id, title, steps, embedding) VALUES ('proc:edit', $1, 'Backend: deploy to staging', $2::jsonb, NULL)`, [TENANT, JSON.stringify(['build'])], ); diff --git a/middleware/test/mcpClient.test.ts b/middleware/test/mcpClient.test.ts new file mode 100644 index 00000000..3eef928b --- /dev/null +++ b/middleware/test/mcpClient.test.ts @@ -0,0 +1,361 @@ +import { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { + createServer, + type IncomingHttpHeaders, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { + LoopbackMcpServer, + McpManager, + type McpServerConfig, +} from '@omadia/orchestrator'; +import type { ToolDispatchService } from '../packages/harness-orchestrator/src/toolDispatchService.js'; + +/** + * W0-5 — the first REAL `McpManager` → MCP-server round trip in the repo. + * + * Everything that existed before only ever exercised failure paths or stubs: + * `mcpCallAudit.test.ts` dials 127.0.0.1:9 (connection refused, no handshake + * possible), `mcpRescan.test.ts` stubs `listTools`, and the cliBridge tests stub + * the loopback server. So no test ever proved the client can complete an + * `initialize` → `tools/list` → `tools/call` sequence over the wire. Everything + * later (including the eventual SDK v2 port) leans on this file. + * + * The tests below drive a live in-process `LoopbackMcpServer` — a real + * Streamable-HTTP MCP server — sometimes through a thin recording proxy that can + * inject one transport-level failure so the retry/pool behaviour is observable + * instead of inferred. + */ + +const BEARER = 'loopback-secret-token'; +const TOOL = 'ping'; + +function serverConfig(url: string, overrides: Partial = {}): McpServerConfig { + return { + id: '00000000-0000-4000-8000-00000000c0de', + name: 'loopback', + transport: 'http', + endpoint: url, + ...overrides, + }; +} + +function fakeDispatch(seen: Array<{ name: string; input: unknown }>): ToolDispatchService { + return { + async dispatch(name: string, input: unknown) { + seen.push({ name, input }); + return { content: `dispatch:${name}:${JSON.stringify(input)}` }; + }, + } as unknown as ToolDispatchService; +} + +function isSandboxListenError(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as { code?: string }).code === 'EPERM' + ); +} + +/** Count how many times the manager dropped a pooled connection. `close` is the + * single invalidation point (connect failure, call failure, stale token). */ +function recordPoolInvalidations(manager: McpManager): () => readonly string[] { + const closed: string[] = []; + const original = manager.close.bind(manager); + manager.close = async (id: string): Promise => { + closed.push(id); + await original(id); + }; + return () => closed; +} + +const HOP_BY_HOP = new Set([ + 'connection', + 'content-length', + 'host', + 'keep-alive', + 'transfer-encoding', + 'upgrade', +]); + +function forwardableHeaders(headers: IncomingHttpHeaders): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (HOP_BY_HOP.has(key.toLowerCase()) || value === undefined) continue; + out[key] = Array.isArray(value) ? value.join(', ') : value; + } + return out; +} + +interface RecordingProxy { + readonly url: string; + readonly postCount: () => number; + readonly toolCallCount: () => number; + readonly stop: () => Promise; +} + +/** + * Transparent HTTP proxy in front of the real MCP server(s). Records POSTs (so a + * doomed extra attempt is countable) and can answer the FIRST `tools/call` with + * a JSON-RPC transport error, which is the only way to observe the deliberate + * once-retry without stubbing the client. + * + * `targets` may hold more than one upstream: after the injected failure the + * proxy advances to the next one. The retry legitimately reconnects (the manager + * drops the pooled connection first), and a `LoopbackMcpServer` holds exactly + * one Streamable-HTTP session — a second `initialize` against the same instance + * is rejected with "Server already initialized". Two instances model the hosted + * proxy this mitigation exists for, where the reconnect lands on a healthy node. + */ +async function startRecordingProxy( + targets: readonly string[], + options: { failFirstToolCall?: boolean } = {}, +): Promise { + let posts = 0; + let toolCalls = 0; + let targetIdx = 0; + + const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk); + } + const body = Buffer.concat(chunks); + if (req.method === 'POST') { + posts += 1; + const text = body.toString('utf8'); + if (text.includes('"tools/call"')) { + toolCalls += 1; + if (options.failFirstToolCall === true && toolCalls === 1) { + const id = (JSON.parse(text) as { id?: unknown }).id ?? null; + targetIdx = Math.min(targetIdx + 1, targets.length - 1); + res.writeHead(200, { 'content-type': 'application/json' }); + // -32000 "Connection closed" is what a flaky hosted proxy actually + // returns; `looksTransient` must classify it as retry-worthy. + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id, + error: { code: -32000, message: 'Connection closed' }, + }), + ); + return; + } + } + } + + const upstream = await fetch(targets[targetIdx] ?? targets[0]!, { + method: req.method ?? 'GET', + headers: forwardableHeaders(req.headers), + ...(body.length > 0 ? { body } : {}), + }); + const responseHeaders: Record = {}; + upstream.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) responseHeaders[key] = value; + }); + res.writeHead(upstream.status, responseHeaders); + if (upstream.body) { + for await (const chunk of upstream.body) { + res.write(Buffer.from(chunk as Uint8Array)); + } + } + res.end(); + }; + + const server: Server = createServer((req, res) => { + void handle(req, res).catch(() => { + if (!res.headersSent) res.writeHead(502); + res.end(); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${String(port)}/mcp`, + postCount: () => posts, + toolCallCount: () => toolCalls, + stop: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +describe('McpManager against a live MCP server (W0-5)', () => { + const servers: LoopbackMcpServer[] = []; + let proxy: RecordingProxy | undefined; + let manager: McpManager | undefined; + + afterEach(async () => { + await manager?.closeAll(); + await proxy?.stop(); + for (const s of servers.splice(0)) await s.stop(); + manager = undefined; + proxy = undefined; + }); + + async function startServer( + t: { skip: (reason: string) => void }, + seen: Array<{ name: string; input: unknown }> = [], + ): Promise { + const server = new LoopbackMcpServer({ + dispatch: fakeDispatch(seen), + bearer: BEARER, + tools: [ + { + name: TOOL, + description: 'echo the input back', + input_schema: { + type: 'object', + properties: { value: { type: 'string' } }, + }, + }, + ], + }); + try { + const handle = await server.start(); + servers.push(server); + return handle.url; + } catch (error) { + if (isSandboxListenError(error)) { + t.skip('sandbox blocks loopback listeners on 127.0.0.1'); + return undefined; + } + throw error; + } + } + + it('completes listTools AND callTool over the wire', async (t) => { + const seen: Array<{ name: string; input: unknown }> = []; + const url = await startServer(t, seen); + if (!url) return; + + manager = new McpManager(); + const cfg = serverConfig(url, { headers: { Authorization: `Bearer ${BEARER}` } }); + + const tools = await manager.listTools(cfg); + assert.equal(tools.length, 1); + assert.equal(tools[0]?.name, TOOL); + assert.equal(tools[0]?.description, 'echo the input back'); + assert.equal(tools[0]?.inputSchema?.['type'], 'object'); + + const result = await manager.callTool(cfg, TOOL, { value: 'hello' }); + assert.equal(result, 'dispatch:ping:{"value":"hello"}'); + assert.deepEqual(seen, [{ name: TOOL, input: { value: 'hello' } }]); + + // A second call must reuse the pooled connection, not reconnect. + const again = await manager.callTool(cfg, TOOL, { value: 'again' }); + assert.equal(again, 'dispatch:ping:{"value":"again"}'); + }); + + it('audits a successful call as ok (the audit path had no success coverage)', async (t) => { + const url = await startServer(t); + if (!url) return; + + const entries: Array<{ ok: boolean; toolName: string; error: string | null }> = []; + manager = new McpManager({ + onToolCall: (e) => entries.push({ ok: e.ok, toolName: e.toolName, error: e.error }), + }); + const cfg = serverConfig(url, { headers: { Authorization: `Bearer ${BEARER}` } }); + + await manager.callTool(cfg, TOOL, {}); + assert.deepEqual(entries, [{ ok: true, toolName: TOOL, error: null }]); + }); + + it('surfaces a genuine Unauthorized immediately — no retry, one pool invalidation', async (t) => { + const url = await startServer(t); + if (!url) return; + proxy = await startRecordingProxy([url]); + + // The loopback server answers a bad bearer with JSON-RPC code -32001, which + // `looksTransient` used to match as a bare number — so a real auth failure + // got one doomed retry before the user saw the authorize prompt. + manager = new McpManager({ + auth: { + getToken: async () => 'stale-token', + onAuthFailure: async () => '🔒 authorize here: https://auth.example/authorize', + }, + }); + const closed = recordPoolInvalidations(manager); + + const result = await manager.callTool(serverConfig(proxy.url), TOOL, {}); + + assert.match(result, /authorize here/); + assert.equal( + proxy.postCount(), + 1, + 'an Unauthorized must not be retried — a second POST means -32001 is still classified transient', + ); + assert.equal( + closed().length, + 1, + 'the stale-token connection must be invalidated exactly once (handleFailure), not twice', + ); + }); + + it('retries a genuinely transient failure exactly once, then succeeds', async (t) => { + const seen: Array<{ name: string; input: unknown }> = []; + const first = await startServer(t, seen); + if (!first) return; + const second = await startServer(t, seen); + if (!second) return; + proxy = await startRecordingProxy([first, second], { failFirstToolCall: true }); + + manager = new McpManager(); + const closed = recordPoolInvalidations(manager); + const cfg = serverConfig(proxy.url, { + headers: { Authorization: `Bearer ${BEARER}` }, + }); + + const result = await manager.callTool(cfg, TOOL, { value: 'retry-me' }); + + // The shipped once-retry mitigation (flaky hosted proxy) must stay: the + // first tools/call fails transiently, the second one succeeds. + assert.equal(result, 'dispatch:ping:{"value":"retry-me"}'); + assert.equal(proxy.toolCallCount(), 2, 'exactly one retry — no more, no fewer'); + assert.equal( + closed().length, + 1, + 'the retry must drop the pooled connection once so it reconnects fresh', + ); + assert.deepEqual(seen, [{ name: TOOL, input: { value: 'retry-me' } }]); + }); + + it('a stale token invalidates the pool and the next call reconnects and succeeds', async (t) => { + const url = await startServer(t); + if (!url) return; + + const tokens = ['stale-token', BEARER]; + manager = new McpManager({ + auth: { + getToken: async () => tokens.shift() ?? BEARER, + // Not an OAuth-protected server: the raw failure must stand so the + // pooling behaviour is what the assertions observe. + onAuthFailure: async () => null, + }, + }); + const closed = recordPoolInvalidations(manager); + + const failed = await manager.callTool(serverConfig(url), TOOL, {}); + assert.match(failed, /^Error: could not connect to MCP server "loopback"/); + assert.equal(closed().length, 1, 'the rejected token must be evicted from the pool'); + + // Same server, fresh (valid) token — a reconnect must happen and succeed. + const recovered = await manager.callTool(serverConfig(url), TOOL, { value: 'after-refresh' }); + assert.equal(recovered, 'dispatch:ping:{"value":"after-refresh"}'); + assert.equal( + closed().length, + 1, + 'a successful reconnect must not invalidate anything further', + ); + }); +}); diff --git a/middleware/test/mcpDelegationBackfillMigration.pg.test.ts b/middleware/test/mcpDelegationBackfillMigration.pg.test.ts new file mode 100644 index 00000000..5e3ca8d5 --- /dev/null +++ b/middleware/test/mcpDelegationBackfillMigration.pg.test.ts @@ -0,0 +1,332 @@ +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { after, before, describe, it } from 'node:test'; + +import { Pool } from 'pg'; + +import { SERVICE_USER_KEY } from '../src/services/mcpDelegation.js'; + +/** + * Migration 0031 (W0-1, D2) — the delegation BACKFILL predicate, against a REAL + * Postgres. + * + * ─── What went wrong ──────────────────────────────────────────────────────── + * + * The backfill's stated intent (0031's own header) is "existing rows that + * already hold an operator token keep today's shared behaviour". The SQL tested + * for ANY token row: + * + * WHERE EXISTS (SELECT 1 FROM mcp_oauth_tokens t WHERE t.server_id = s.id) + * + * `mcp_oauth_tokens` is keyed `(server_id, user_key)`, so a server holding only + * `user_key = 'alice@corp.com'` matched and was flipped to `delegation = + * 'service'`. That is a silent identity change: `resolveMcpUserKey` then hands + * the shared `operator` key to EVERY caller of that server. The immediate + * symptom is fail-closed breakage (no operator token exists to resolve), but the + * lasting one is worse — once anyone completes a re-auth, the minted operator + * token is shared by every caller, including the unmapped channel users the + * confused-deputy fix exists to stop. + * + * ─── Isolation rules this file obeys ──────────────────────────────────────── + * + * 1. A dedicated SCHEMA, never a scratch DATABASE. `CREATE/DROP DATABASE` are + * cluster-wide and abort other connections; a previous run cancelled 29 + * tests in concurrent files that way. + * 2. A dedicated tenant id in the schema name — the suites here share one + * cluster and run concurrently, and the migration-runner advisory lock is + * database-wide. + * 3. `search_path` pinned as a CONNECTION OPTION, not via `SET`. A `SET` binds + * only the pooled client that served it, so the next query would silently + * resolve against `public`. + * + * Only the tables 0031 touches are hand-built, at migrations 0003/0009/0015 + * shapes — not the whole chain. What is under test is 0031's own DML. + * + * ─── The migration text is used verbatim ──────────────────────────────────── + * + * 0031 used to guard its backfill with `to_regclass('public.mcp_oauth_tokens')` + * — the one schema-QUALIFIED reference in a file that is otherwise entirely + * unqualified. Under rule 1 the tables live in the tenant schema, so that guard + * answered about a table this migration never touches, and this file had to + * rewrite the literal before running it. The guard is now unqualified and + * resolves through `search_path` like everything else, so the file is applied + * AS SHIPPED and `migrationSql()` fails loudly if an executable `public.`- + * qualified reference is ever reintroduced — a silent short-circuit would + * otherwise make every assertion below pass vacuously. + */ + +const PG_URL = + process.env['GRAPH_PG_TEST_URL'] ?? + process.env['MEMORY_PG_TEST_URL'] ?? + process.env['DATABASE_URL'] ?? + 'postgres://test:test@127.0.0.1:55438/test'; + +let pgAvailable = true; +try { + const probe = new Pool({ connectionString: PG_URL, connectionTimeoutMillis: 1_500 }); + await probe.query('SELECT 1'); + await probe.end(); +} catch { + pgAvailable = false; +} + +/** Dedicated tenant id → dedicated schema. See rule 2 above. */ +const TENANT = `w4_deleg_${process.pid}_${Date.now().toString(36)}`; + +const MIGRATION_PATH = new URL('../migrations/0031_mcp_oauth_iss_delegation.sql', import.meta.url); + +function stripWholeLineSqlComments(sql: string): string { + // Whole-line `--` stripping is sufficient for this file: the migration's + // false positives live in prose comments, and its executable statements do + // not use trailing `--` comments that would need SQL-aware parsing. + return sql + .split('\n') + .filter((line) => !line.trimStart().startsWith('--')) + .join('\n'); +} + +/** The migration text, applied verbatim. See the header for why no rewrite is + * needed, and what the public-qualified guard protects. */ +async function migrationSql(): Promise { + const raw = await readFile(MIGRATION_PATH, 'utf8'); + const executable = stripWholeLineSqlComments(raw); + assert.equal( + /\bpublic\s*\.|"public"\s*\./i.test(executable), + false, + 'migration 0031 gained an executable public-qualified reference — it must resolve through search_path, ' + + 'or this suite runs it against tables it does not own and passes vacuously', + ); + return raw; +} + +describe('migration 0031 — delegation backfill predicate (pg)', { skip: !pgAvailable }, () => { + let admin: Pool; + let pool: Pool; + + before(async () => { + admin = new Pool({ connectionString: PG_URL }); + await admin.query(`CREATE SCHEMA "${TENANT}"`); + pool = new Pool({ connectionString: PG_URL, options: `-c search_path=${TENANT}` }); + + // The tables 0031 alters, at their pre-0031 shapes. + await pool.query(` + CREATE TABLE mcp_servers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + transport TEXT NOT NULL CHECK (transport IN ('stdio', 'http', 'sse')), + endpoint TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE mcp_oauth_tokens ( + server_id UUID NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + user_key TEXT NOT NULL, + access_token_ref TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (server_id, user_key) + ); + CREATE TABLE mcp_oauth_flows ( + state TEXT PRIMARY KEY, + server_id UUID NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + user_key TEXT NOT NULL, + issuer TEXT NOT NULL, + code_verifier TEXT NOT NULL, + redirect_uri TEXT NOT NULL + ); + CREATE TABLE mcp_call_log ( + id BIGSERIAL PRIMARY KEY, + server_name TEXT NOT NULL, + tool_name TEXT NOT NULL, + ok BOOLEAN NOT NULL, + duration_ms INTEGER NOT NULL + ); + `); + + // Four servers spanning the whole predicate space. Populated BEFORE the + // migration runs, so the backfill is proven on a non-empty table. + await pool.query(` + INSERT INTO mcp_servers (name, transport, endpoint) VALUES + ('operator-only', 'http', 'https://a.example'), + ('per-user-only', 'http', 'https://b.example'), + ('mixed', 'http', 'https://c.example'), + ('no-tokens', 'http', 'https://d.example') + `); + await pool.query( + `INSERT INTO mcp_oauth_tokens (server_id, user_key, access_token_ref) + SELECT s.id, k.user_key, 'vault://' || s.name || '/' || k.user_key + FROM mcp_servers s + JOIN (VALUES + ('operator-only', $1), + ('per-user-only', 'alice@corp.com'), + ('mixed', 'bob@corp.com'), + ('mixed', $1) + ) AS k(server_name, user_key) ON k.server_name = s.name`, + [SERVICE_USER_KEY], + ); + + await pool.query(await migrationSql()); + }); + + after(async () => { + // Schema-scoped teardown. No CREATE/DROP DATABASE anywhere. + await pool.end(); + await admin.query(`DROP SCHEMA IF EXISTS "${TENANT}" CASCADE`); + await admin.end(); + }); + + async function delegationOf(name: string): Promise { + const { rows } = await pool.query<{ delegation: string }>( + `SELECT delegation FROM mcp_servers WHERE name = $1`, + [name], + ); + return rows[0]?.delegation; + } + + async function applyMigration(): Promise { + await pool.query(await migrationSql()); + } + + async function hasDelegationConstraint(): Promise { + const { rows } = await pool.query<{ present: boolean }>( + `SELECT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'mcp_servers_delegation_chk' + AND connamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1) + AND conrelid = 'mcp_servers'::regclass + ) AS present`, + [TENANT], + ); + return rows[0]?.present ?? false; + } + + async function ensureDelegationConstraint(): Promise { + await pool.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'mcp_servers_delegation_chk' + AND conrelid = 'mcp_servers'::regclass + ) THEN + ALTER TABLE mcp_servers + ADD CONSTRAINT mcp_servers_delegation_chk + CHECK (delegation IN ('per_user', 'service')); + END IF; + END $$; + `); + } + + it('does NOT flip a server holding only a NON-operator token', async () => { + // THE regression. The broad `EXISTS (… WHERE server_id = s.id)` predicate + // matched this row and handed every future caller the shared operator key. + assert.equal( + await delegationOf('per-user-only'), + 'per_user', + 'a per-user server was silently converted to a shared service identity', + ); + }); + + it('DOES flip a server holding an operator token — the grandfathering still works', async () => { + // The other half. Narrowing the predicate must not break the compatibility + // the migration exists to provide: these installs work TODAY only because of + // the `?? operator` fallback D2 removes. + assert.equal(await delegationOf('operator-only'), 'service'); + }); + + it('flips a MIXED server — one operator token is enough, other user keys do not veto it', async () => { + assert.equal(await delegationOf('mixed'), 'service'); + }); + + it('leaves a token-less server on the safe per_user default', async () => { + assert.equal(await delegationOf('no-tokens'), 'per_user'); + }); + + it('uses the SAME literal the runtime resolves as the shared key', async () => { + // The migration cannot import `SERVICE_USER_KEY`, so the two literals can + // drift. If they ever do, the backfill grandfathers a different set of + // servers than the runtime can actually resolve tokens for. + const executable = stripWholeLineSqlComments(await readFile(MIGRATION_PATH, 'utf8')); + assert.equal(SERVICE_USER_KEY, 'operator'); + assert.match(executable, new RegExp(`user_key\\s*=\\s*'${SERVICE_USER_KEY}'`)); + }); + + // These two used to be a documented NOT-TESTED hole. 0031 guarded its ALTER + // with `IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = …)` — no + // relation and no namespace filter, so the lookup was cluster-wide. Under the + // per-suite schema isolation this file requires, a CONCURRENT pg suite that + // created the constraint in ITS schema made the guard true here and the + // constraint was skipped in ours: the assertion passed in isolation and failed + // in the full-suite run. The guard is now anchored on + // `conrelid = 'mcp_servers'::regclass`, so the coverage is real, and the + // assertions below are scoped to THIS suite's schema for the same reason. + + it('creates the delegation CHECK in THIS schema, not merely somewhere in the cluster', async () => { + assert.equal( + await hasDelegationConstraint(), + true, + 'the CHECK was skipped in this schema — the guard matched a constraint owned by another schema', + ); + }); + + it('the created CHECK actually constrains — an unknown delegation mode is rejected', async () => { + // Existence alone would still pass if the constraint were created empty or + // over the wrong column. This proves the predicate 0031 claims to install. + await assert.rejects( + () => + pool.query( + `INSERT INTO mcp_servers (name, transport, endpoint, delegation) + VALUES ('bad-delegation', 'http', 'https://e.example', 'telepathy')`, + ), + (err: unknown) => { + const pgErr = err as { code?: string; constraint?: string }; + assert.equal(pgErr.code, '23514'); + assert.equal(pgErr.constraint, 'mcp_servers_delegation_chk'); + return true; + }, + 'delegation accepted a value outside (per_user, service)', + ); + }); + + it('re-applying preserves an operator opt-in from service back to per_user', async () => { + try { + await pool.query(`UPDATE mcp_servers SET delegation = 'per_user' WHERE name = 'operator-only'`); + await applyMigration(); + assert.equal( + await delegationOf('operator-only'), + 'per_user', + 're-applying 0031 silently overrode an operator opt-in back to service', + ); + } finally { + await pool.query(`UPDATE mcp_servers SET delegation = 'service' WHERE name = 'operator-only'`); + } + }); + + it('re-applying recreates the delegation CHECK even when the column already exists', async () => { + let dropped = false; + try { + await pool.query(`ALTER TABLE mcp_servers DROP CONSTRAINT mcp_servers_delegation_chk`); + dropped = true; + assert.equal(await hasDelegationConstraint(), false); + await applyMigration(); + assert.equal( + await hasDelegationConstraint(), + true, + 'the top-level CHECK guard stopped repairing a table that already had the delegation column', + ); + } finally { + if (dropped) { + await ensureDelegationConstraint(); + } + } + }); + + it('is idempotent — re-applying flips nothing further', async () => { + // A second run must be a no-op, not a second chance to convert a per-user + // server (e.g. if one acquired an operator token in between, that is a real + // change; if not, nothing may move). + await applyMigration(); + assert.equal(await delegationOf('per-user-only'), 'per_user'); + assert.equal(await delegationOf('operator-only'), 'service'); + assert.equal(await delegationOf('no-tokens'), 'per_user'); + }); +}); diff --git a/middleware/test/mcpInputReplyContract.test.ts b/middleware/test/mcpInputReplyContract.test.ts new file mode 100644 index 00000000..cc2934c2 --- /dev/null +++ b/middleware/test/mcpInputReplyContract.test.ts @@ -0,0 +1,58 @@ +/** + * Issue #544 (W2-1) — the one duplicated constant in this feature. + * + * `MCP_INPUT_REPLY_PREFIX` exists twice: once in + * `harness-orchestrator/src/mcp/pendingMcpInput.ts` (which parses it) and once + * in `web-ui/app/_components/chat/McpInputCard.tsx` (which produces it). web-ui + * does not depend on the middleware packages, so it cannot be imported. + * + * A drift between the two is silent and total: the orchestrator would stop + * recognising card answers, the envelope would land in the chat as literal text, + * and every parked MCP call would expire unanswered. Nothing would throw. So the + * pair is pinned here, by reading the web-ui source. + * + * The envelope FORMAT is pinned too, not just the prefix — the middleware parser + * is fed the exact string the card builds. + */ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { MCP_INPUT_REPLY_PREFIX, parseMcpInputReply } from '@omadia/orchestrator'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CARD = resolve(HERE, '../../web-ui/app/_components/chat/McpInputCard.tsx'); + +describe('MCP input reply envelope — middleware ↔ web-ui contract (#544 W2-1)', () => { + const source = readFileSync(CARD, 'utf8'); + + it('MUTATION CHECK: web-ui declares the SAME prefix the orchestrator parses', () => { + const declared = /export const MCP_INPUT_REPLY_PREFIX = '([^']+)';/.exec(source)?.[1]; + assert.ok(declared, 'web-ui no longer declares MCP_INPUT_REPLY_PREFIX'); + // Changing either side alone turns this red — which is the only reason the + // duplication is acceptable at all. + assert.equal(declared, MCP_INPUT_REPLY_PREFIX); + }); + + it("MUTATION CHECK: the orchestrator parses the card's exact envelope format", () => { + // Reproduces `formatMcpInputReply` from the card verbatim. If the card ever + // changes its serialization (a different separator, a wrapper object, an + // extra field), this drifts from the real parser and turns red. + const wire = `${MCP_INPUT_REPLY_PREFIX} ${JSON.stringify({ + correlationId: 'corr-abc', + inputResponses: { customerNumber: 'K-1234' }, + })}`; + const parsed = parseMcpInputReply(wire); + assert.ok(parsed, `the orchestrator could not parse the card's envelope: ${wire}`); + assert.equal(parsed.correlationId, 'corr-abc'); + assert.deepEqual(parsed.inputResponses, { customerNumber: 'K-1234' }); + }); + + it('web-ui still builds the envelope through the shared helper', () => { + // Guards against someone inlining the string at a call site, which would + // escape the prefix assertion above. + assert.match(source, /\$\{MCP_INPUT_REPLY_PREFIX\}/); + }); +}); diff --git a/middleware/test/mcpOAuth.test.ts b/middleware/test/mcpOAuth.test.ts index f938c329..3c28ee8b 100644 --- a/middleware/test/mcpOAuth.test.ts +++ b/middleware/test/mcpOAuth.test.ts @@ -1,10 +1,22 @@ import { describe, it } from 'node:test'; import { strict as assert } from 'node:assert'; import { createHash } from 'node:crypto'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import express from 'express'; import { McpAuthDiscovery } from '../src/services/mcpAuthDiscovery.js'; import { McpOAuthClient, type OAuthClientCredentials } from '../src/services/mcpOAuthClient.js'; import type { AuthServerMetadata } from '../src/services/mcpAuthDiscovery.js'; +import { + SERVICE_USER_KEY, + UNRESOLVED_IDENTITY, + auditIdentity, + parseDelegation, + resolveMcpUserKey, +} from '../src/services/mcpDelegation.js'; +import { redactSecrets, redactedErrorText } from '../src/services/secretRedaction.js'; function jsonResponder(routes: Record): typeof fetch { return (async (input: RequestInfo | URL) => { @@ -112,6 +124,8 @@ const AS: AuthServerMetadata = { codeChallengeMethods: ['S256'], grantTypes: ['authorization_code'], scopesSupported: ['read'], + issParameterSupported: false, + clientIdMetadataDocumentSupported: false, }; const CLIENT: OAuthClientCredentials = { clientId: 'cid', clientSecret: 'sec' }; @@ -200,7 +214,14 @@ describe('McpOAuthClient', () => { describe('McpOAuthService.describeAuth (broker classification)', () => { const server = { id: 's', name: 'srv', endpoint: 'https://srv.example/mcp' } as never; - const deps = { graph: {} as never, vault: {} as never, redirectUri: 'https://host/cb' }; + // W2-4: describeAuth now reads the stored client FIRST (to report which + // acquisition mode an issuer is already on), so the graph stub has to answer + // getMcpOAuthClient even for issuers that never reach the DCR probe. + const deps = { + graph: { getMcpOAuthClient: async () => undefined } as never, + vault: {} as never, + redirectUri: 'https://host/cb', + }; it('brokered=true when DCR actually succeeds (zero-setup)', async () => { const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); @@ -258,3 +279,1066 @@ describe('McpOAuthService.describeAuth (broker classification)', () => { assert.equal((await svc.describeAuth(server)).protected, false); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// W0-1 — three live defects in the MCP OAuth path +// D1 no RFC 9207 `iss` validation at the callback +// D2 silent 'operator' fallback (confused deputy) +// D3 unbounded concurrent refreshes for the same (server, user) +// ───────────────────────────────────────────────────────────────────────────── + +/** An in-memory stand-in for the parts of AgentGraphStore the OAuth service + * touches. Records writes so a test can assert that a REJECTED callback + * persisted nothing. */ +function fakeGraph(opts?: { + flow?: Record; + token?: Record | undefined; + client?: { clientId: string; clientSecretRef: string | null } | null; +}): { + graph: never; + tokenWrites: Record[]; + tokenDeletes: { serverId: string; userKey: string }[]; + flowCreates: Record[]; +} { + const tokenWrites: Record[] = []; + const tokenDeletes: { serverId: string; userKey: string }[] = []; + const flowCreates: Record[] = []; + let flow = opts?.flow; + const graph = { + // One-shot, like the real DELETE … RETURNING. + takeMcpOAuthFlow: async (state: string) => { + if (!flow || flow['state'] !== state) return undefined; + const taken = flow; + flow = undefined; + return taken; + }, + createMcpOAuthFlow: async (input: Record) => { + flowCreates.push(input); + }, + getMcpOAuthToken: async () => opts?.token, + upsertMcpOAuthToken: async (input: Record) => { + tokenWrites.push(input); + }, + deleteMcpOAuthToken: async (serverId: string, userKey: string) => { + tokenDeletes.push({ serverId, userKey }); + }, + getMcpOAuthClient: async () => + opts?.client === undefined ? { clientId: 'cid', clientSecretRef: null } : opts.client, + upsertMcpOAuthClient: async () => {}, + } as never; + return { graph, tokenWrites, tokenDeletes, flowCreates }; +} + +const FLOW_BASE = { + state: 'ST', + serverId: 'srv-1', + userKey: 'user-a', + issuer: 'https://as.example', + codeVerifier: 'VERIFIER-VALUE', + redirectUri: 'https://host/cb', + scopes: 'read', + tokenEndpoint: 'https://as.example/token', + authorizationEndpoint: 'https://as.example/authorize', + issRequired: false, +}; + +describe('W0-1 D1 — RFC 9207 iss validation at the OAuth callback', () => { + it('parses authorization_response_iss_parameter_supported from AS metadata', async () => { + const fetchImpl = jsonResponder({ + '/.well-known/oauth-protected-resource': { + authorization_servers: ['https://as.example'], + }, + '/.well-known/oauth-authorization-server': { + issuer: 'https://as.example', + authorization_endpoint: 'https://as.example/authorize', + token_endpoint: 'https://as.example/token', + authorization_response_iss_parameter_supported: true, + }, + }); + const out = await new McpAuthDiscovery({ fetchImpl }).discover('https://as.example/mcp'); + assert.equal(out?.server.issParameterSupported, true); + }); + + it('defaults issParameterSupported to false when the AS does not advertise it', async () => { + const fetchImpl = jsonResponder({ + '/.well-known/oauth-protected-resource': { + authorization_servers: ['https://as.example'], + }, + '/.well-known/oauth-authorization-server': { + issuer: 'https://as.example', + authorization_endpoint: 'https://as.example/authorize', + token_endpoint: 'https://as.example/token', + }, + }); + const out = await new McpAuthDiscovery({ fetchImpl }).discover('https://as.example/mcp'); + assert.equal(out?.server.issParameterSupported, false); + }); + + it('records the AS iss support on the flow at authorize time (not re-discovered later)', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph, flowCreates } = fakeGraph(); + const discovery = { + discover: async () => ({ + resource: { + resource: 'https://srv.example', + authorizationServers: ['https://as.example'], + scopesSupported: ['read'], + bearerMethods: ['header'], + }, + server: { ...AS, issParameterSupported: true }, + }), + } as never; + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + discovery, + }); + await svc.beginAuthorization( + { id: 'srv-1', name: 'srv', endpoint: 'https://srv.example/mcp', transport: 'http' } as never, + 'user-a', + ); + assert.equal(flowCreates.length, 1); + assert.equal(flowCreates[0]?.['issRequired'], true); + assert.equal(flowCreates[0]?.['issuer'], 'https://as.example'); + }); + + it('accepts a matching iss and stores the token', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph, tokenWrites } = fakeGraph({ flow: { ...FLOW_BASE, issRequired: true } }); + const client = { + exchangeCode: async () => ({ + accessToken: 'AT', + refreshToken: 'RT', + expiresInSec: 3600, + scope: 'read', + }), + } as never; + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + client, + }); + const out = await svc.completeAuthorization('ST', 'CODE', 'https://as.example'); + assert.equal(out.serverId, 'srv-1'); + assert.equal(tokenWrites.length, 1, 'a valid callback stores exactly one token'); + // AC3: the token is bound to the issuer that minted it. + assert.equal(tokenWrites[0]?.['issuer'], 'https://as.example'); + }); + + it('tolerates a single trailing slash difference in the issuer (RFC 9207 §2.4)', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph, tokenWrites } = fakeGraph({ flow: { ...FLOW_BASE, issRequired: true } }); + const client = { + exchangeCode: async () => ({ + accessToken: 'AT', + refreshToken: null, + expiresInSec: null, + scope: null, + }), + } as never; + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + client, + }); + await svc.completeAuthorization('ST', 'CODE', 'https://as.example/'); + assert.equal(tokenWrites.length, 1); + }); + + it('REJECTS a mismatched iss and persists nothing', async () => { + const { McpOAuthService, McpOAuthIssuerMismatchError } = await import( + '../src/services/mcpOAuthService.js' + ); + const { graph, tokenWrites } = fakeGraph({ flow: { ...FLOW_BASE } }); + let exchanged = false; + const client = { + exchangeCode: async () => { + exchanged = true; + return { accessToken: 'AT', refreshToken: 'RT', expiresInSec: 3600, scope: null }; + }, + } as never; + const vaultWrites: string[] = []; + const svc = new McpOAuthService({ + graph, + vault: { + get: async () => undefined, + set: async (_ns: string, k: string) => { + vaultWrites.push(k); + }, + } as never, + redirectUri: 'https://host/cb', + client, + }); + await assert.rejects( + () => svc.completeAuthorization('ST', 'CODE', 'https://evil.example'), + (err: unknown) => err instanceof McpOAuthIssuerMismatchError, + ); + // The whole point: hard rejection BEFORE the exchange, so no code leaves + // and no credential lands anywhere. + assert.equal(exchanged, false, 'the code must never be exchanged on a mismatch'); + assert.equal(tokenWrites.length, 0, 'no token row may be written'); + assert.deepEqual(vaultWrites, [], 'no secret may be written to the vault'); + }); + + it('REJECTS an absent iss when the AS advertised support for it', async () => { + const { McpOAuthService, McpOAuthIssuerMismatchError } = await import( + '../src/services/mcpOAuthService.js' + ); + const { graph, tokenWrites } = fakeGraph({ flow: { ...FLOW_BASE, issRequired: true } }); + let exchanged = false; + const client = { + exchangeCode: async () => { + exchanged = true; + return { accessToken: 'AT', refreshToken: null, expiresInSec: null, scope: null }; + }, + } as never; + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + client, + }); + await assert.rejects( + () => svc.completeAuthorization('ST', 'CODE', null), + (err: unknown) => + err instanceof McpOAuthIssuerMismatchError && err.received === null, + ); + assert.equal(exchanged, false); + assert.equal(tokenWrites.length, 0); + }); + + it('accepts an absent iss when the AS never advertised support (backward compatible)', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph, tokenWrites } = fakeGraph({ flow: { ...FLOW_BASE, issRequired: false } }); + const client = { + exchangeCode: async () => ({ + accessToken: 'AT', + refreshToken: null, + expiresInSec: null, + scope: null, + }), + } as never; + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + client, + }); + await svc.completeAuthorization('ST', 'CODE', null); + assert.equal(tokenWrites.length, 1, 'pre-RFC-9207 providers keep working'); + }); + + it('treats a blank iss as absent rather than as a mismatch', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph, tokenWrites } = fakeGraph({ flow: { ...FLOW_BASE, issRequired: false } }); + const client = { + exchangeCode: async () => ({ + accessToken: 'AT', + refreshToken: null, + expiresInSec: null, + scope: null, + }), + } as never; + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + client, + }); + await svc.completeAuthorization('ST', 'CODE', ' '); + assert.equal(tokenWrites.length, 1); + }); +}); + +describe('W0-1 D2 — delegation: fail closed instead of borrowing the operator identity', () => { + it('per_user + resolvable identity → that identity', () => { + assert.equal(resolveMcpUserKey({ delegation: 'per_user' }, 'alice@example.com'), 'alice@example.com'); + }); + + it('per_user + UNRESOLVABLE identity → null (never the operator)', () => { + for (const candidate of [null, undefined, '', ' ']) { + const resolved = resolveMcpUserKey({ delegation: 'per_user' }, candidate); + assert.equal(resolved, null, `candidate ${JSON.stringify(candidate)} must not resolve`); + assert.notEqual(resolved, SERVICE_USER_KEY); + assert.notEqual(resolved, 'operator'); + } + }); + + it('service delegation is the explicit opt-in that keeps a shared identity', () => { + assert.equal(resolveMcpUserKey({ delegation: 'service' }, null), SERVICE_USER_KEY); + // Grandfathered rows must keep resolving to the historical literal, or + // migration 0031 would silently orphan their stored tokens. + assert.equal(SERVICE_USER_KEY, 'operator'); + }); + + it('service delegation ignores a caller identity (one shared token by design)', () => { + assert.equal(resolveMcpUserKey({ delegation: 'service' }, 'alice@example.com'), SERVICE_USER_KEY); + }); + + it('audit identity is never blank — an unattributable call is recorded as such', () => { + assert.equal(auditIdentity({ delegation: 'per_user' }, null), UNRESOLVED_IDENTITY); + assert.equal(auditIdentity({ delegation: 'per_user' }, 'bob'), 'bob'); + assert.equal(auditIdentity({ delegation: 'service' }, null), SERVICE_USER_KEY); + }); + + it('parseDelegation rejects anything outside the CHECK constraint', () => { + assert.equal(parseDelegation('per_user'), 'per_user'); + assert.equal(parseDelegation('service'), 'service'); + for (const bad of ['operator', 'PER_USER', '', null, undefined, 1, {}]) { + assert.equal(parseDelegation(bad), null, `${JSON.stringify(bad)} must not parse`); + } + }); + + it('an unresolved per_user identity yields NO token from the service', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + // A token DOES exist under the shared key — the old code would have found + // and used it. Resolution must never reach this call. + let lookups = 0; + const graph = { + getMcpOAuthToken: async () => { + lookups += 1; + return { accessTokenRef: 'ref', refreshTokenRef: null, expiresAt: null, scopes: null, issuer: null }; + }, + } as never; + const svc = new McpOAuthService({ + graph, + vault: { get: async () => 'SHARED-OPERATOR-TOKEN', set: async () => {} } as never, + redirectUri: 'https://host/cb', + }); + const server = { id: 'srv-1', name: 'srv', delegation: 'per_user' as const }; + const userKey = resolveMcpUserKey(server, undefined); + assert.equal(userKey, null); + // The production call sites short-circuit on null, so the operator's token + // is never even looked up. + const token = userKey === null ? null : await svc.getValidAccessToken(server as never, userKey); + assert.equal(token, null); + assert.equal(lookups, 0, 'the shared token must not be consulted at all'); + }); +}); + +describe('W0-1 D3 — concurrent refresh is single-flight (MUTATION-CHECKED)', () => { + /** Build a service whose refresh path goes over a real McpOAuthClient, so the + * assertion counts genuine HTTP requests to the token endpoint rather than + * mock invocations. */ + async function refreshHarness(): Promise<{ + svc: import('../src/services/mcpOAuthService.js').McpOAuthService; + server: never; + tokenPosts: () => number; + tokenWrites: Record[]; + }> { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + let tokenPosts = 0; + const fetchImpl: typeof fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url === 'https://as.example/token') { + tokenPosts += 1; + // Rotating refresh token, as OAuth 2.1 recommends — this is precisely + // what makes a lost race destructive. + return new Response( + JSON.stringify({ + access_token: `AT-${String(tokenPosts)}`, + refresh_token: `RT-${String(tokenPosts)}`, + expires_in: 3600, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + return new Response('not found', { status: 404 }); + }) as typeof fetch; + + const tokenWrites: Record[] = []; + const graph = { + // Expired 60s ago → inside the refresh margin, so every caller wants a refresh. + getMcpOAuthToken: async () => ({ + serverId: 'srv-1', + userKey: 'user-a', + accessTokenRef: 'token/srv-1/user-a/access', + refreshTokenRef: 'token/srv-1/user-a/refresh', + expiresAt: new Date(Date.now() - 60_000), + scopes: 'read', + issuer: 'https://as.example', + }), + upsertMcpOAuthToken: async (input: Record) => { + tokenWrites.push(input); + }, + getMcpOAuthClient: async () => ({ clientId: 'cid', clientSecretRef: null }), + deleteMcpOAuthToken: async () => {}, + } as never; + const vaultStore = new Map([ + ['token/srv-1/user-a/access', 'STALE-AT'], + ['token/srv-1/user-a/refresh', 'RT-0'], + ]); + const vault = { + get: async (_ns: string, k: string) => vaultStore.get(k), + set: async (_ns: string, k: string, v: string) => { + vaultStore.set(k, v); + }, + } as never; + const discovery = { + discover: async () => ({ + resource: { + resource: 'https://srv.example', + authorizationServers: ['https://as.example'], + scopesSupported: ['read'], + bearerMethods: ['header'], + }, + server: AS, + }), + } as never; + const svc = new McpOAuthService({ + graph, + vault, + redirectUri: 'https://host/cb', + discovery, + client: new McpOAuthClient({ fetchImpl }), + }); + const server = { + id: 'srv-1', + name: 'srv', + endpoint: 'https://srv.example/mcp', + transport: 'http', + delegation: 'per_user', + } as never; + return { svc, server, tokenPosts: () => tokenPosts, tokenWrites }; + } + + it('issues exactly ONE token-endpoint HTTP request for N concurrent callers', async () => { + const { svc, server, tokenPosts, tokenWrites } = await refreshHarness(); + const N = 8; + const results = await Promise.all( + Array.from({ length: N }, () => svc.getValidAccessToken(server, 'user-a')), + ); + // THE mutation check: remove the in-flight map and this becomes 8. + // Counting mock calls would not prove this — the count is of real HTTP + // requests made through fetch to the token endpoint. + assert.equal(tokenPosts(), 1, `expected exactly 1 token request, got ${String(tokenPosts())}`); + // One refresh ⇒ one persisted rotation. N writes would mean N-1 of them + // stored a refresh token the AS had already retired. + assert.equal(tokenWrites.length, 1, 'exactly one token rotation may be persisted'); + // Every caller gets the same live token — nobody is handed a loser's result. + assert.deepEqual(new Set(results), new Set(['AT-1'])); + }); + + it('a later refresh is not blocked by the completed one (the map is cleared)', async () => { + const { svc, server, tokenPosts } = await refreshHarness(); + await svc.getValidAccessToken(server, 'user-a'); + await svc.getValidAccessToken(server, 'user-a'); + assert.equal(tokenPosts(), 2, 'sequential refreshes must each do their own request'); + }); + + it('different users do not share one refresh', async () => { + const { svc, server, tokenPosts } = await refreshHarness(); + await Promise.all([ + svc.getValidAccessToken(server, 'user-a'), + svc.getValidAccessToken(server, 'user-b'), + ]); + assert.equal(tokenPosts(), 2, 'the in-flight key must include the user'); + }); + + it('drops a stored token whose issuer has rotated instead of replaying it', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + let tokenPosts = 0; + const fetchImpl: typeof fetch = (async () => { + tokenPosts += 1; + return new Response(JSON.stringify({ access_token: 'AT' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + const deletes: { serverId: string; userKey: string }[] = []; + const graph = { + getMcpOAuthToken: async () => ({ + accessTokenRef: 'a', + refreshTokenRef: 'r', + expiresAt: new Date(Date.now() - 60_000), + scopes: null, + // Minted by the OLD issuer. + issuer: 'https://old-as.example', + }), + upsertMcpOAuthToken: async () => {}, + getMcpOAuthClient: async () => ({ clientId: 'cid', clientSecretRef: null }), + deleteMcpOAuthToken: async (serverId: string, userKey: string) => { + deletes.push({ serverId, userKey }); + }, + } as never; + const svc = new McpOAuthService({ + graph, + vault: { + get: async (_ns: string, k: string) => (k === 'r' ? 'RT' : 'STALE-AT'), + set: async () => {}, + } as never, + redirectUri: 'https://host/cb', + // Discovery now reports a DIFFERENT issuer. + discovery: { + discover: async () => ({ + resource: { + resource: 'https://srv.example', + authorizationServers: ['https://as.example'], + scopesSupported: [], + bearerMethods: [], + }, + server: AS, + }), + } as never, + client: new McpOAuthClient({ fetchImpl }), + }); + const token = await svc.getValidAccessToken( + { id: 'srv-1', name: 'srv', endpoint: 'https://srv.example/mcp', transport: 'http' } as never, + 'user-a', + ); + assert.equal(token, null, 'a token from a rotated issuer must not be usable'); + assert.equal(tokenPosts, 0, 'the old refresh token must not be sent to the new issuer'); + assert.deepEqual(deletes, [{ serverId: 'srv-1', userKey: 'user-a' }]); + }); +}); + +describe('W0-1 D5 — no token, code, or code_verifier can reach a log line', () => { + it('redacts an exact secret value wherever it appears', () => { + const out = redactSecrets('refresh failed for RT-abcdefgh12345 (retry)', ['RT-abcdefgh12345']); + assert.ok(!out.includes('RT-abcdefgh12345'), out); + assert.ok(out.includes('[redacted]')); + }); + + it('redacts a secret the provider echoed back URL-encoded', () => { + const secret = 'tok/with+special=chars'; + const out = redactSecrets(`error: value=${encodeURIComponent(secret)}`, [secret]); + assert.ok(!out.includes(encodeURIComponent(secret)), out); + }); + + it('redacts token fields in a JSON error body we did not mint', () => { + const body = '{"error":"invalid_grant","access_token":"AT-SECRET-1","refresh_token":"RT-SECRET-2"}'; + const out = redactSecrets(body); + assert.ok(!out.includes('AT-SECRET-1'), out); + assert.ok(!out.includes('RT-SECRET-2'), out); + // Non-secret diagnostics must survive, or the log becomes useless. + assert.ok(out.includes('invalid_grant'), out); + }); + + it('redacts code and code_verifier from a query string or form body', () => { + const out = redactSecrets( + 'POST https://as.example/token?code=THE-AUTH-CODE&code_verifier=THE-VERIFIER&client_id=cid', + ); + assert.ok(!out.includes('THE-AUTH-CODE'), out); + assert.ok(!out.includes('THE-VERIFIER'), out); + assert.ok(out.includes('client_id=cid'), 'client_id is not a secret'); + }); + + it('redacts a bearer token echoed in an error', () => { + const out = redactSecrets('upstream said: Authorization: Bearer eyJhbGciOi.SECRET.PART'); + assert.ok(!out.includes('eyJhbGciOi.SECRET.PART'), out); + }); + + it('redactedErrorText never leaks the refresh token from a thrown Error', () => { + const err = new Error('token endpoint rejected refresh_token=RT-LEAKY-VALUE for client cid'); + const out = redactedErrorText(err, ['RT-LEAKY-VALUE']); + assert.ok(!out.includes('RT-LEAKY-VALUE'), out); + assert.ok(out.includes('Error:'), 'the error class stays visible for debugging'); + }); + + it('leaves short values alone rather than shredding unrelated text', () => { + // A 3-char "secret" would otherwise redact every occurrence of those chars. + assert.equal(redactSecrets('the cat sat', ['cat']), 'the cat sat'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// W2-4 — Client ID Metadata Documents (issue #546, CIMD half) +// +// The issue body claims omadia "only supports static headers with secretRef". +// It does not: the whole provider-agnostic OAuth 2.1 + PKCE stack exercised +// above shipped in epic #459 W9. These tests cover the DELTA — a third +// client-acquisition mode that coexists PERMANENTLY with the manual path, +// because Entra ID and Okta do not support CIMD and use pre-registered apps. +// ───────────────────────────────────────────────────────────────────────────── + +/** An AS that advertises `client_id_metadata_document_supported`. */ +const CIMD_AS: AuthServerMetadata = { ...AS, clientIdMetadataDocumentSupported: true }; + +/** Discovery stub returning a chosen authorization-server metadata document. */ +function discoveryFor(as: AuthServerMetadata): never { + return { + discover: async () => ({ + resource: { + resource: 'https://srv.example', + authorizationServers: [as.issuer], + scopesSupported: ['read'], + bearerMethods: ['header'], + }, + server: as, + }), + } as never; +} + +/** Graph stub recording every client upsert, so a test can assert WHICH mode + * the chain actually persisted rather than merely that something happened. */ +function clientGraph(stored?: { + clientId: string; + clientSecretRef: string | null; + registeredVia: string; + clientMetadataUrl?: string | null; +}): { graph: never; upserts: Array> } { + const upserts: Array> = []; + const graph = { + getMcpOAuthClient: async () => (stored ? { issuer: CIMD_AS.issuer, ...stored } : undefined), + upsertMcpOAuthClient: async (input: Record) => { + upserts.push(input); + }, + createMcpOAuthFlow: async () => {}, + } as never; + return { graph, upserts }; +} + +/** A fetch that serves a VALID CIMD document at one URL and 404s elsewhere — + * i.e. an install whose ingress really does route `/.well-known/*` inbound. */ +function cimdServing(metadataUrl: string, redirectUri = 'https://host/cb'): typeof fetch { + return (async (input: RequestInfo | URL) => { + if (String(input) === metadataUrl) { + return new Response( + JSON.stringify({ + client_id: metadataUrl, + client_name: 'omadia MCP', + redirect_uris: [redirectUri], + token_endpoint_auth_method: 'none', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + return new Response('not found', { status: 404 }); + }) as typeof fetch; +} + +// `omadia.example` is a reserved-TLD name: the SSRF guard's DNS lookup cannot +// resolve it, and `assertPublicHttpsUrl` deliberately does not hard-block an +// unresolvable host (the fetch would fail loudly anyway) — so the stubbed fetch +// is what decides reachability in these tests, which is the point. +const MD_URL = 'https://omadia.example/.well-known/omadia-mcp-client'; +const SERVER_ROW = { id: 's', name: 'srv', endpoint: 'https://srv.example/mcp' } as never; + +const AUTHORIZE_STUB = { + buildAuthorizeUrl: (args: { client: { clientId: string } }) => ({ + url: `https://as.example/authorize?client_id=${encodeURIComponent(args.client.clientId)}`, + state: 'st', + codeVerifier: 'v', + }), +}; + +describe('W2-4 — client-acquisition chain (stored → cimd → dcr → manual)', () => { + it('stored beats cimd: an existing client short-circuits, nothing is re-acquired', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph, upserts } = clientGraph({ + clientId: 'already-here', + clientSecretRef: null, + registeredVia: 'manual', + }); + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + cimdMetadataUrl: MD_URL, + cimdFetchImpl: cimdServing(MD_URL), + discovery: discoveryFor({ ...CIMD_AS, registrationEndpoint: 'https://as.example/register' }), + client: { + registerClient: async () => ({ clientId: 'dcr-cid', clientSecret: null }), + ...AUTHORIZE_STUB, + } as never, + }); + const { authorizeUrl } = await svc.beginAuthorization(SERVER_ROW, 'u'); + // The real invariant is not "a mock went uncalled": it is that the stored + // client is what reaches the provider AND that nothing was re-persisted, so + // an operator's manual client cannot be silently replaced by a CIMD one. + assert.ok(authorizeUrl.includes('client_id=already-here'), authorizeUrl); + assert.deepEqual(upserts, [], 'a stored client must never be overwritten by the chain'); + }); + + it('cimd beats dcr: a CIMD-capable AS is acquired via the document', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph, upserts } = clientGraph(); + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + cimdMetadataUrl: MD_URL, + cimdFetchImpl: cimdServing(MD_URL), + // An AS offering BOTH: CIMD must win. + discovery: discoveryFor({ ...CIMD_AS, registrationEndpoint: 'https://as.example/register' }), + client: { + registerClient: async () => ({ clientId: 'dcr-cid', clientSecret: 'dcr-sec' }), + ...AUTHORIZE_STUB, + } as never, + }); + const { authorizeUrl } = await svc.beginAuthorization(SERVER_ROW, 'u'); + assert.equal(upserts.length, 1); + assert.equal(upserts[0]?.['registeredVia'], 'cimd'); + assert.equal(upserts[0]?.['clientId'], MD_URL, 'the client_id IS the metadata URL'); + assert.equal(upserts[0]?.['clientMetadataUrl'], MD_URL); + assert.equal(upserts[0]?.['clientSecretRef'], null, 'a CIMD client is public — no secret'); + // Observable consequence, not a call count: a regression letting DCR win + // would change the client_id that actually reaches the provider. + assert.ok(authorizeUrl.includes(encodeURIComponent(MD_URL)), authorizeUrl); + assert.ok(!authorizeUrl.includes('dcr-cid'), authorizeUrl); + }); + + it('cimd is SKIPPED when the AS does not advertise support (dcr wins)', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph, upserts } = clientGraph(); + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + // CIMD is fully configured AND reachable — the ONLY reason it must not be + // used is that this AS never promised to dereference a document. + cimdMetadataUrl: MD_URL, + cimdFetchImpl: cimdServing(MD_URL), + discovery: discoveryFor({ + ...AS, + clientIdMetadataDocumentSupported: false, + registrationEndpoint: 'https://as.example/register', + }), + client: { + registerClient: async () => ({ clientId: 'dcr-cid', clientSecret: null }), + ...AUTHORIZE_STUB, + } as never, + }); + const { authorizeUrl } = await svc.beginAuthorization(SERVER_ROW, 'u'); + assert.equal(upserts[0]?.['registeredVia'], 'dcr'); + assert.ok(authorizeUrl.includes('client_id=dcr-cid'), authorizeUrl); + }); + + it('dcr keeps working and is NOT removed — it only warns about deprecation', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph, upserts } = clientGraph(); + const logs: string[] = []; + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + discovery: discoveryFor({ ...AS, registrationEndpoint: 'https://as.example/register' }), + client: { + registerClient: async () => ({ clientId: 'dcr-cid', clientSecret: null }), + ...AUTHORIZE_STUB, + } as never, + log: (m) => logs.push(m), + }); + await svc.beginAuthorization(SERVER_ROW, 'u'); + assert.equal(upserts[0]?.['registeredVia'], 'dcr', 'DCR must still succeed'); + assert.ok( + logs.some((l) => /deprecat/i.test(l)), + `a deprecation warning is required, got: ${JSON.stringify(logs)}`, + ); + }); + + it('manual last: an AS with neither CIMD nor DCR raises McpOAuthNeedsClientError', async () => { + const { McpOAuthService, McpOAuthNeedsClientError } = await import( + '../src/services/mcpOAuthService.js' + ); + const { graph } = clientGraph(); + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + discovery: discoveryFor(AS), + client: { registerClient: async () => null, ...AUTHORIZE_STUB } as never, + }); + await assert.rejects( + () => svc.beginAuthorization(SERVER_ROW, 'u'), + (err: unknown) => err instanceof McpOAuthNeedsClientError, + ); + }); + + it('a CIMD-capable AS still degrades to manual when the document is unreachable', async () => { + // THE on-prem case: the AS would happily dereference a document, but this + // install has no inbound https route, so nothing can fetch it. The chain + // must fall through rather than hand over an unresolvable client_id. + const { McpOAuthService, McpOAuthNeedsClientError } = await import( + '../src/services/mcpOAuthService.js' + ); + const { graph, upserts } = clientGraph(); + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + cimdMetadataUrl: MD_URL, + // Ingress does not route `/.well-known/*` inbound → 404. + cimdFetchImpl: (async () => new Response('nope', { status: 404 })) as typeof fetch, + discovery: discoveryFor(CIMD_AS), + client: { registerClient: async () => null, ...AUTHORIZE_STUB } as never, + }); + await assert.rejects( + () => svc.beginAuthorization(SERVER_ROW, 'u'), + (err: unknown) => err instanceof McpOAuthNeedsClientError, + ); + assert.deepEqual(upserts, [], 'an unreachable document must persist no client'); + }); + + it('cimd is skipped with no metadata URL at all (FLOW_PUBLIC_BASE_URL unset)', async () => { + const { McpOAuthService, McpOAuthNeedsClientError } = await import( + '../src/services/mcpOAuthService.js' + ); + const { graph, upserts } = clientGraph(); + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined, set: async () => {} } as never, + redirectUri: 'https://host/cb', + cimdMetadataUrl: null, + discovery: discoveryFor(CIMD_AS), + client: { registerClient: async () => null, ...AUTHORIZE_STUB } as never, + }); + await assert.rejects( + () => svc.beginAuthorization(SERVER_ROW, 'u'), + (err: unknown) => err instanceof McpOAuthNeedsClientError, + ); + assert.deepEqual(upserts, []); + }); +}); + +describe('W2-4 — describeAuth reports the acquisition mode', () => { + it('acquisitionMode=cimd and brokered=true when CIMD is live', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph } = clientGraph(); + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined } as never, + redirectUri: 'https://host/cb', + cimdMetadataUrl: MD_URL, + cimdFetchImpl: cimdServing(MD_URL), + discovery: discoveryFor(CIMD_AS), + client: { registerClient: async () => null } as never, + }); + const d = await svc.describeAuth(SERVER_ROW); + assert.equal(d.acquisitionMode, 'cimd'); + assert.equal(d.cimdSupported, true); + assert.equal(d.cimdBlockedReason, null); + assert.equal(d.brokered, true); + }); + + it('reports cimdSupported with a blocked reason on a firewalled install', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph } = clientGraph(); + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined } as never, + redirectUri: 'https://host/cb', + cimdMetadataUrl: null, + discovery: discoveryFor(CIMD_AS), + client: { registerClient: async () => null } as never, + }); + const d = await svc.describeAuth(SERVER_ROW); + assert.equal(d.cimdSupported, true, 'the AS DID advertise CIMD'); + assert.equal(d.cimdBlockedReason, 'no_public_base_url'); + // Manual is the answer here, and it is a supported answer — not an error. + assert.equal(d.acquisitionMode, 'manual'); + assert.equal(d.brokered, false); + }); + + it('a stored manual client reports manual and is never re-probed as cimd', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph, upserts } = clientGraph({ + clientId: 'entra-app-id', + clientSecretRef: 'client/x/secret', + registeredVia: 'manual', + }); + const svc = new McpOAuthService({ + graph, + vault: { get: async () => 'sec' } as never, + redirectUri: 'https://host/cb', + cimdMetadataUrl: MD_URL, + cimdFetchImpl: cimdServing(MD_URL), + discovery: discoveryFor(CIMD_AS), + client: { registerClient: async () => null } as never, + }); + const d = await svc.describeAuth(SERVER_ROW); + assert.equal(d.acquisitionMode, 'manual'); + assert.deepEqual(upserts, [], 'describeAuth must not mutate a stored client'); + }); + + it('a stored cimd client reports acquisitionMode=cimd', async () => { + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const { graph } = clientGraph({ + clientId: MD_URL, + clientSecretRef: null, + registeredVia: 'cimd', + clientMetadataUrl: MD_URL, + }); + const svc = new McpOAuthService({ + graph, + vault: { get: async () => undefined } as never, + redirectUri: 'https://host/cb', + cimdMetadataUrl: MD_URL, + discovery: discoveryFor(CIMD_AS), + client: { registerClient: async () => null } as never, + }); + const d = await svc.describeAuth(SERVER_ROW); + assert.equal(d.acquisitionMode, 'cimd'); + assert.equal(d.brokered, true); + }); +}); + +describe('W2-4 — SSRF guard on the CIMD metadata URL', () => { + it('refuses a loopback metadata URL without ever fetching it', async () => { + const { probeCimdReachable } = await import('../src/services/mcpCimd.js'); + let fetched = 0; + const r = await probeCimdReachable({ + metadataUrl: 'https://127.0.0.1/.well-known/omadia-mcp-client', + fetchImpl: (async () => { + fetched += 1; + return new Response('{}', { status: 200 }); + }) as typeof fetch, + }); + assert.equal(r.reachable, false); + assert.equal(r.reason, 'not_public_https'); + assert.equal(fetched, 0, 'the guard must run BEFORE any request leaves'); + }); + + it('refuses an RFC1918 metadata URL', async () => { + const { probeCimdReachable } = await import('../src/services/mcpCimd.js'); + const r = await probeCimdReachable({ + metadataUrl: 'https://10.1.2.3/.well-known/omadia-mcp-client', + }); + assert.equal(r.reason, 'not_public_https'); + }); + + it('refuses a link-local (cloud metadata service) URL', async () => { + const { probeCimdReachable } = await import('../src/services/mcpCimd.js'); + const r = await probeCimdReachable({ + metadataUrl: 'https://169.254.169.254/.well-known/omadia-mcp-client', + }); + assert.equal(r.reason, 'not_public_https'); + }); + + it('refuses a non-https metadata URL', async () => { + const { probeCimdReachable } = await import('../src/services/mcpCimd.js'); + const r = await probeCimdReachable({ + metadataUrl: 'http://omadia.example/.well-known/omadia-mcp-client', + }); + assert.equal(r.reason, 'not_public_https'); + }); + + it('refuses a document whose client_id is not the URL we fetched', async () => { + // A catch-all proxy route answering 200 with someone else's document must + // not read as "our document is reachable". + const { probeCimdReachable } = await import('../src/services/mcpCimd.js'); + const r = await probeCimdReachable({ + metadataUrl: MD_URL, + fetchImpl: (async () => + new Response(JSON.stringify({ client_id: 'https://someone.else/doc' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as typeof fetch, + }); + assert.equal(r.reachable, false); + assert.equal(r.reason, 'document_mismatch'); + }); + + it('accepts a genuinely public, self-consistent document', async () => { + const { probeCimdReachable } = await import('../src/services/mcpCimd.js'); + const r = await probeCimdReachable({ metadataUrl: MD_URL, fetchImpl: cimdServing(MD_URL) }); + assert.equal(r.reachable, true); + assert.equal(r.reason, null); + }); +}); + +describe('W2-4 — GET /.well-known/omadia-mcp-client', () => { + async function serve(opts: { + metadataUrl: string | null; + redirectUri: string | null; + }): Promise<{ baseUrl: string; close: () => Promise }> { + const { createMcpClientMetadataRouter } = await import('../src/routes/mcpClientMetadata.js'); + const app = express(); + app.use(createMcpClientMetadataRouter(opts)); + const server: Server = await new Promise((resolve) => { + const s = app.listen(0, () => resolve(s)); + }); + const port = (server.address() as AddressInfo).port; + return { + baseUrl: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; + } + + it('serves the document shape a CIMD-aware AS expects', async () => { + const h = await serve({ metadataUrl: MD_URL, redirectUri: 'https://host/cb' }); + try { + const res = await fetch(`${h.baseUrl}/.well-known/omadia-mcp-client`); + assert.equal(res.status, 200); + const doc = (await res.json()) as Record; + assert.equal(doc['client_id'], MD_URL, 'client_id must be self-referential'); + assert.deepEqual(doc['redirect_uris'], ['https://host/cb']); + assert.equal(doc['token_endpoint_auth_method'], 'none'); + assert.equal(typeof doc['client_name'], 'string'); + } finally { + await h.close(); + } + }); + + it('answers 501 with an actionable message when FLOW_PUBLIC_BASE_URL is unset', async () => { + const h = await serve({ metadataUrl: null, redirectUri: 'https://host/cb' }); + try { + const res = await fetch(`${h.baseUrl}/.well-known/omadia-mcp-client`); + // 501, not 500: this is a configuration state, not a fault. A firewalled + // install lives here permanently and the manual path still works. + assert.equal(res.status, 501); + const body = (await res.json()) as { error: string; message: string }; + assert.equal(body.error, 'cimd_unavailable'); + assert.ok( + body.message.includes('FLOW_PUBLIC_BASE_URL'), + 'the message must name the knob to set', + ); + assert.ok( + /Entra|Okta|manual/.test(body.message), + 'the message must point at the still-supported manual path', + ); + } finally { + await h.close(); + } + }); + + it('answers 501 when no redirect URI is configured (nothing valid to advertise)', async () => { + const h = await serve({ metadataUrl: MD_URL, redirectUri: null }); + try { + assert.equal((await fetch(`${h.baseUrl}/.well-known/omadia-mcp-client`)).status, 501); + } finally { + await h.close(); + } + }); + + it('served redirect_uris matches McpOAuthService.redirectUri EXACTLY', async () => { + // If these ever diverge, the AS matches the authorize request's + // redirect_uri against this document, finds no match, and every code + // exchange fails — at the provider, far from the cause. Both are wired from + // one variable in index.ts; this pins that they stay one value. + const { McpOAuthService } = await import('../src/services/mcpOAuthService.js'); + const redirectUri = 'https://omadia.example/api/v1/operator/mcp-oauth/callback'; + const svc = new McpOAuthService({ + graph: { getMcpOAuthClient: async () => undefined } as never, + vault: {} as never, + redirectUri, + cimdMetadataUrl: MD_URL, + }); + const h = await serve({ metadataUrl: MD_URL, redirectUri: svc.redirectUri }); + try { + const doc = (await (await fetch(`${h.baseUrl}/.well-known/omadia-mcp-client`)).json()) as { + redirect_uris: string[]; + }; + assert.deepEqual(doc.redirect_uris, [svc.redirectUri]); + assert.equal(doc.redirect_uris[0], redirectUri); + } finally { + await h.close(); + } + }); + + it('the metadata URL is stable across restarts (from config, not the Host header)', async () => { + const { cimdMetadataUrl } = await import('../src/services/mcpCimd.js'); + // Same config in, same client_id out — including across a trailing-slash + // difference, which would otherwise mint two client_ids for one install. + assert.equal(cimdMetadataUrl('https://omadia.example'), MD_URL); + assert.equal(cimdMetadataUrl('https://omadia.example/'), MD_URL); + assert.equal(cimdMetadataUrl(undefined), null); + assert.equal(cimdMetadataUrl(null), null); + }); +}); diff --git a/middleware/test/mcpOAuthCimdMigration.pg.test.ts b/middleware/test/mcpOAuthCimdMigration.pg.test.ts new file mode 100644 index 00000000..7fe17017 --- /dev/null +++ b/middleware/test/mcpOAuthCimdMigration.pg.test.ts @@ -0,0 +1,156 @@ +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { after, before, describe, it } from 'node:test'; + +import { Pool } from 'pg'; + +/** + * Migration 0032 (W2-4, issue #546) against a REAL Postgres. + * + * Two isolation rules this file obeys deliberately, both learned the hard way: + * + * 1. **A dedicated SCHEMA, never a scratch DATABASE.** `CREATE DATABASE` / + * `DROP DATABASE` are cluster-wide operations: they take locks that abort + * other connections to the cluster, and a previous run of this repo's pg + * suites cancelled 29 tests in concurrent files that way. A schema is + * namespaced, cheap, and droppable with `CASCADE` without touching anyone + * else's tables. + * 2. **A dedicated tenant id in the schema name.** The suites in this repo + * share one cluster and run concurrently, so a fixed schema name would let + * two runs collide. The id is per-file, not per-test, because `before` + * builds the schema once. + * + * The migration is applied to a MINIMAL hand-built `mcp_oauth_clients` matching + * migration 0015's shape — not the whole 31-migration chain. What is under test + * is 0032's own DDL: does the CHECK constraint admit 'cimd', does it still + * reject junk, and does `client_metadata_url` exist and accept NULL. + */ + +const PG_URL = + process.env['GRAPH_PG_TEST_URL'] ?? + process.env['MEMORY_PG_TEST_URL'] ?? + process.env['DATABASE_URL'] ?? + 'postgres://test:test@127.0.0.1:55438/test'; + +let pgAvailable = true; +try { + const probe = new Pool({ connectionString: PG_URL, connectionTimeoutMillis: 1_500 }); + await probe.query('SELECT 1'); + await probe.end(); +} catch { + pgAvailable = false; +} + +/** Dedicated tenant id → dedicated schema. See rule 2 above. */ +const TENANT = `w24_cimd_${process.pid}_${Date.now().toString(36)}`; + +const MIGRATION_PATH = new URL('../migrations/0032_mcp_oauth_cimd.sql', import.meta.url); + +describe('migration 0032 — mcp_oauth_clients CIMD (pg)', { skip: !pgAvailable }, () => { + /** Creates and drops the tenant schema. Separate from `pool` because the + * schema must exist before a search_path-scoped connection can resolve. */ + let admin: Pool; + /** Every query in the suite runs here. The search_path is pinned as a + * CONNECTION OPTION, not via a `SET` statement: a `SET` binds only the one + * pooled client that happened to serve it, so the next query — on a + * different client — would silently fall back to `public`. That bug made two + * of these tests fail on the first run, which is exactly the kind of + * cross-connection leakage a scratch database would have hidden. */ + let pool: Pool; + + before(async () => { + admin = new Pool({ connectionString: PG_URL }); + await admin.query(`CREATE SCHEMA "${TENANT}"`); + pool = new Pool({ connectionString: PG_URL, options: `-c search_path=${TENANT}` }); + // Migration 0015's table shape, verbatim in the parts 0032 alters. + await pool.query(` + CREATE TABLE mcp_oauth_clients ( + issuer TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + client_secret_ref TEXT, + registered_via TEXT NOT NULL CHECK (registered_via IN ('dcr', 'manual')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + // A pre-existing row, so the migration is proven to run on a NON-empty + // table — an ALTER that only works on an empty one is not a migration. + await pool.query( + `INSERT INTO mcp_oauth_clients (issuer, client_id, registered_via) + VALUES ('https://legacy.example', 'legacy-cid', 'manual')`, + ); + const sql = await readFile(MIGRATION_PATH, 'utf8'); + await pool.query(sql); + }); + + after(async () => { + // Schema-scoped teardown. No CREATE/DROP DATABASE anywhere — those are + // cluster-wide and abort other connections to the same cluster. + await pool.end(); + await admin.query(`DROP SCHEMA IF EXISTS "${TENANT}" CASCADE`); + await admin.end(); + }); + + it("admits registered_via = 'cimd' after the migration", async () => { + await pool.query( + `INSERT INTO mcp_oauth_clients (issuer, client_id, registered_via, client_metadata_url) + VALUES ('https://broker.example', 'https://omadia.example/.well-known/omadia-mcp-client', + 'cimd', 'https://omadia.example/.well-known/omadia-mcp-client')`, + ); + const { rows } = await pool.query<{ registered_via: string; client_metadata_url: string }>( + `SELECT registered_via, client_metadata_url FROM mcp_oauth_clients + WHERE issuer = 'https://broker.example'`, + ); + assert.equal(rows[0]?.registered_via, 'cimd'); + assert.equal( + rows[0]?.client_metadata_url, + 'https://omadia.example/.well-known/omadia-mcp-client', + ); + }); + + it("still admits 'dcr' and 'manual' — neither mode is retired", async () => { + await pool.query( + `INSERT INTO mcp_oauth_clients (issuer, client_id, registered_via) + VALUES ('https://dcr.example', 'd', 'dcr'), ('https://manual.example', 'm', 'manual')`, + ); + const { rows } = await pool.query<{ n: string }>( + `SELECT count(*)::text AS n FROM mcp_oauth_clients WHERE registered_via IN ('dcr','manual')`, + ); + // The pre-migration 'manual' row plus the two just inserted. + assert.equal(rows[0]?.n, '3'); + }); + + it('still REJECTS an unknown acquisition mode (the CHECK was widened, not dropped)', async () => { + await assert.rejects( + () => + pool.query( + `INSERT INTO mcp_oauth_clients (issuer, client_id, registered_via) + VALUES ('https://bogus.example', 'b', 'telepathy')`, + ), + (err: unknown) => (err as { code?: string }).code === '23514', + 'a widened CHECK must still constrain', + ); + }); + + it('leaves client_metadata_url NULL for the pre-existing manual row', async () => { + const { rows } = await pool.query<{ client_metadata_url: string | null }>( + `SELECT client_metadata_url FROM mcp_oauth_clients WHERE issuer = 'https://legacy.example'`, + ); + assert.equal(rows[0]?.client_metadata_url, null); + }); + + it('is idempotent — re-applying changes nothing and raises nothing', async () => { + const sql = await readFile(MIGRATION_PATH, 'utf8'); + await pool.query(sql); + await pool.query( + `INSERT INTO mcp_oauth_clients (issuer, client_id, registered_via) + VALUES ('https://again.example', 'a', 'cimd')`, + ); + const { rows } = await pool.query<{ n: string }>( + `SELECT count(*)::text AS n FROM pg_constraint + WHERE conname = 'mcp_oauth_clients_registered_via_chk' + AND connamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)`, + [TENANT], + ); + assert.equal(rows[0]?.n, '1', 'the named constraint must exist exactly once'); + }); +}); diff --git a/middleware/test/mcpPendingInput.test.ts b/middleware/test/mcpPendingInput.test.ts new file mode 100644 index 00000000..5562e714 --- /dev/null +++ b/middleware/test/mcpPendingInput.test.ts @@ -0,0 +1,886 @@ +/** + * Issue #544 (W2-1) — MRTR `resultType: "input_required"` mid-call user input. + * + * Four things are locked down here: + * + * 1. The store. TTL, single-use `take`, and — the security-relevant one — + * that the `{userId, sessionId, correlationId}` triple actually isolates: + * a lookup differing in ANY component must miss. `sessionScope` alone is a + * known-unsafe key (`resolveScope` returns the literal `'http-default'`, + * the live cross-user hole from #445). + * 2. `parseMcpInputRequests` — a malformed `inputRequests` must degrade to a + * plain tool error, never to a half-rendered card. + * 3. `McpManager.callTool` over a REAL MCP connection: an `input_required` + * result parks the call, returns a sentinel, consumes no retry attempt, and + * audits as neither success nor failure. + * 4. The full two-turn round trip, including `inputResponses` reaching the + * server verbatim and a second `input_required` on replay being capped. + * + * Several tests are explicitly labelled MUTATION CHECK: each was verified by + * breaking the implementation, rebuilding, and confirming this assertion (not + * an invocation count) turns red. See the PR body for the log. + */ + +import { after, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { + createServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { Server as McpSdkServer } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + CallToolResultSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import { + InMemoryPendingMcpInputStore, + claimMcpInputFromResults, + MCP_INPUT_REPLY_PREFIX, + MCP_INPUT_REQUEST_MAX_FIELDS, + MCP_INPUT_REQUIRED_SENTINEL_PREFIX, + McpManager, + formatMcpInputReply, + isInputRequiredResult, + mcpInputRequiredSentinel, + parseMcpInputSentinel, + parseMcpInputReply, + parseMcpInputRequests, + turnContext, + type McpCallLogEntry, + type McpServerConfig, + type McpSidecarPayload, + type PendingMcpInput, +} from '@omadia/orchestrator'; + +// ── fixtures ──────────────────────────────────────────────────────────────── + +function record(over?: Partial): PendingMcpInput { + return { + correlationId: 'corr-1', + serverId: 'srv-1', + serverName: 'Kunden-CRM', + toolName: 'create_ticket', + originalArgs: { subject: 'Drucker kaputt' }, + inputRequests: [{ name: 'customerNumber', required: true }], + replayDepth: 0, + ...over, + }; +} + +const KEY = { userId: 'u1', sessionId: 's1', correlationId: 'corr-1' } as const; + +// ── 1. the store ──────────────────────────────────────────────────────────── + +const OWNER = { userId: 'u1', sessionId: 's1' } as const; + +describe('InMemoryPendingMcpInputStore (#544 W2-1)', () => { + it('round-trips park → claim → take', () => { + const store = new InMemoryPendingMcpInputStore(); + assert.equal(store.put(record()), 'stored'); + assert.deepEqual(store.claim('corr-1', OWNER), record()); + assert.deepEqual(store.take(KEY), record()); + }); + + it('MUTATION CHECK: an UNCLAIMED record is replayable by nobody', () => { + const store = new InMemoryPendingMcpInputStore(); + store.put(record()); + // Parked but never claimed: the manager binds no owner, so no `take` can + // succeed — not even with the right correlation id. Defaulting the owner to + // the key at park time (or skipping the owner check in `take`) turns this + // red, and would let a guessed id be redeemed by anyone. + assert.equal(store.take(KEY), undefined); + assert.equal(store.take({ userId: null, sessionId: null, correlationId: 'corr-1' }), undefined); + // …and the failed attempts must not have destroyed it. + assert.ok(store.claim('corr-1', OWNER)); + assert.ok(store.take(KEY)); + }); + + it('take is single-use — a second take of the same key misses', () => { + const store = new InMemoryPendingMcpInputStore(); + store.put(record()); + store.claim('corr-1', OWNER); + assert.ok(store.take(KEY)); + assert.equal(store.take(KEY), undefined); + assert.equal(store.size(), 0); + }); + + it('MUTATION CHECK: expires a record past the hard TTL', () => { + let clock = 1_000; + const store = new InMemoryPendingMcpInputStore({ ttlMs: 5_000, now: () => clock }); + store.put(record()); + store.claim('corr-1', OWNER); + clock += 4_999; + assert.ok(store.take(KEY), 'still inside the TTL window'); + + store.put(record()); + store.claim('corr-1', OWNER); + clock += 5_001; + // The record is UNREACHABLE, not merely flagged. Removing the expiry + // comparison in `take` turns this red. + assert.equal(store.take(KEY), undefined); + }); + + it('MUTATION CHECK: an expired record can no longer be claimed either', () => { + let clock = 0; + const store = new InMemoryPendingMcpInputStore({ ttlMs: 100, now: () => clock }); + store.put(record()); + clock += 101; + // Otherwise a card could still be rendered for a call that is long gone. + assert.equal(store.claim('corr-1', OWNER), undefined); + }); + + it('drops expired records from the map rather than leaking them', () => { + let clock = 0; + const store = new InMemoryPendingMcpInputStore({ ttlMs: 100, now: () => clock }); + store.put(record({ correlationId: 'a' })); + store.put(record({ correlationId: 'b' })); + assert.equal(store.size(), 2); + clock += 101; + assert.equal(store.size(), 0); + }); + + // ── the security requirement ───────────────────────────────────────────── + it('MUTATION CHECK: cross-SESSION isolation — same user + same correlationId, different session, misses', () => { + const store = new InMemoryPendingMcpInputStore(); + store.put(record({ correlationId: 'c' })); + // `'http-default'` is precisely the literal `resolveScope` hands back for + // unscoped HTTP turns, so this pair is the #445 shape. + store.claim('c', { userId: 'u1', sessionId: 'http-default' }); + assert.equal( + store.take({ userId: 'u1', sessionId: 'other-session', correlationId: 'c' }), + undefined, + ); + // …and the record is still there for its rightful owner, i.e. the miss was + // a miss, not a silent consume. + assert.ok(store.take({ userId: 'u1', sessionId: 'http-default', correlationId: 'c' })); + }); + + it('MUTATION CHECK: cross-USER isolation — same session + same correlationId, different user, misses', () => { + const store = new InMemoryPendingMcpInputStore(); + store.put(record({ correlationId: 'c' })); + store.claim('c', { userId: 'victim', sessionId: 'http-default' }); + // The #445 hole in one line: sharing `sessionScope` must NOT be enough. + assert.equal( + store.take({ userId: 'attacker', sessionId: 'http-default', correlationId: 'c' }), + undefined, + ); + assert.ok(store.take({ userId: 'victim', sessionId: 'http-default', correlationId: 'c' })); + }); + + it('null identity is distinct from the string "null"', () => { + const store = new InMemoryPendingMcpInputStore(); + store.put(record({ correlationId: 'c' })); + store.claim('c', { userId: null, sessionId: null }); + assert.equal(store.take({ userId: 'null', sessionId: 'null', correlationId: 'c' }), undefined); + assert.ok(store.take({ userId: null, sessionId: null, correlationId: 'c' })); + }); + + it('a delimiter inside a component cannot forge another owner', () => { + const store = new InMemoryPendingMcpInputStore(); + store.put(record({ correlationId: 'c' })); + store.claim('c', { userId: 'a', sessionId: 'b' }); + assert.equal(store.take({ userId: 'a","b', sessionId: '', correlationId: 'c' }), undefined); + assert.equal(store.take({ userId: 'a', sessionId: 'b","c', correlationId: 'c' }), undefined); + assert.ok(store.take({ userId: 'a', sessionId: 'b', correlationId: 'c' })); + }); + + // ── claim semantics ────────────────────────────────────────────────────── + it('MUTATION CHECK: claim does NOT consume the replayable record', () => { + const store = new InMemoryPendingMcpInputStore(); + store.put(record()); + assert.ok(store.claim('corr-1', OWNER), 'the drain sees the card'); + // THE invariant: the replay happens in a LATER turn, so claiming must leave + // the record intact. Making `claim` delete the entry (the obvious + // "symmetry" refactor) turns this red — and would make every replay fail. + assert.deepEqual(store.take(KEY), record()); + }); + + it('MUTATION CHECK: claim is single-shot — the FIRST claimant wins', () => { + const store = new InMemoryPendingMcpInputStore(); + store.put(record()); + assert.ok(store.claim('corr-1', OWNER)); + // A second claim — a re-scan, a replayed sentinel, or another turn — misses. + // Without this a leaked sentinel string could re-bind the record to a + // different owner, which is the cross-user hole in a different costume. + assert.equal(store.claim('corr-1', { userId: 'u2', sessionId: 's2' }), undefined); + // Ownership stayed with the first claimant. + assert.equal(store.take({ userId: 'u2', sessionId: 's2', correlationId: 'corr-1' }), undefined); + assert.ok(store.take(KEY)); + }); + + it('claiming an unknown correlationId is a miss, not a throw', () => { + const store = new InMemoryPendingMcpInputStore(); + assert.equal(store.claim('never-parked', OWNER), undefined); + }); + + it('drop discards a record outright', () => { + const store = new InMemoryPendingMcpInputStore(); + store.put(record()); + store.drop('corr-1'); + assert.equal(store.size(), 0); + assert.equal(store.claim('corr-1', OWNER), undefined); + }); + + it('refuses to park a card raised by a replay (bounce cap)', () => { + const store = new InMemoryPendingMcpInputStore(); + assert.equal(store.put(record({ replayDepth: 1 })), 'replay_capped'); + assert.equal(store.claim('corr-1', OWNER), undefined); + assert.equal(store.size(), 0); + }); + + it('evicts oldest-first past the entry cap', () => { + const store = new InMemoryPendingMcpInputStore({ maxEntries: 2 }); + for (const id of ['a', 'b', 'c']) store.put(record({ correlationId: id })); + assert.equal(store.size(), 2); + assert.equal(store.claim('a', OWNER), undefined); + assert.ok(store.claim('c', OWNER)); + }); +}); + +// ── 1b. claimMcpInputFromResults ──────────────────────────────────────────── + +describe('claimMcpInputFromResults (#544 W2-1)', () => { + const sentinel = (id: string): string => + mcpInputRequiredSentinel(record({ correlationId: id })); + + it('MUTATION CHECK: claims the FIRST sentinel and drops the rest', () => { + const store = new InMemoryPendingMcpInputStore(); + for (const id of ['a', 'b', 'c']) store.put(record({ correlationId: id })); + const claimed = claimMcpInputFromResults( + store, + [sentinel('a'), 'ordinary result', sentinel('b'), sentinel('c')], + OWNER, + ); + assert.equal(claimed?.correlationId, 'a'); + // The losers must not linger: an orphan would be a card nobody renders, + // holding a parked server call until its TTL. Removing the `drop` leaves + // size at 3 and turns this red. + assert.equal(store.size(), 1); + assert.ok(store.take({ ...OWNER, correlationId: 'a' })); + }); + + it('MUTATION CHECK: a sentinel echoed INSIDE a result cannot forge a card', () => { + const store = new InMemoryPendingMcpInputStore(); + store.put(record({ correlationId: 'a' })); + // The sentinel is the WHOLE string the manager returns, so a hostile server + // that embeds the marker in its own prose must not be able to raise a card. + // Switching the parser to `includes` turns this red. + const claimed = claimMcpInputFromResults( + store, + [`Hier ist mein Output. ${sentinel('a')}`], + OWNER, + ); + assert.equal(claimed, undefined); + assert.equal(store.size(), 1, 'and it must not have been dropped either'); + }); + + it('returns undefined for a batch with no sentinel at all', () => { + const store = new InMemoryPendingMcpInputStore(); + assert.equal( + claimMcpInputFromResults(store, ['just text', '{"ok":true}', ''], OWNER), + undefined, + ); + }); + + it('parseMcpInputSentinel round-trips and rejects near-misses', () => { + assert.equal(parseMcpInputSentinel(sentinel('abc')), 'abc'); + for (const bad of [ + 'ordinary result', + '', + '[mcp_input_required]', + '[mcp_input_required:', + '[mcp_input_required:] rest', + '[mcp_input_required: ] rest', + ]) { + assert.equal(parseMcpInputSentinel(bad), undefined, `parsed: ${bad}`); + } + }); +}); + +// ── 2. parseMcpInputRequests ──────────────────────────────────────────────── + +describe('parseMcpInputRequests (#544 W2-1)', () => { + it('accepts a well-formed list', () => { + const out = parseMcpInputRequests([ + { name: 'customerNumber', label: 'Kundennummer', description: 'K-1234' }, + ]); + assert.equal(out.ok, true); + assert.deepEqual(out.ok ? out.fields : undefined, [ + { + name: 'customerNumber', + label: 'Kundennummer', + description: 'K-1234', + required: true, + }, + ]); + }); + + it('accepts name / id / key and label / title aliases', () => { + for (const [key, alias] of [ + ['name', 'a'], + ['id', 'b'], + ['key', 'c'], + ] as const) { + const out = parseMcpInputRequests([{ [key]: alias }]); + assert.equal(out.ok && out.fields[0]?.name, alias); + } + const titled = parseMcpInputRequests([{ name: 'x', title: 'Titel' }]); + assert.equal(titled.ok && titled.fields[0]?.label, 'Titel'); + }); + + it('treats a field as required unless it says otherwise', () => { + const implicit = parseMcpInputRequests([{ name: 'x' }]); + assert.equal(implicit.ok && implicit.fields[0]?.required, true); + const explicit = parseMcpInputRequests([{ name: 'x', required: false }]); + assert.equal(explicit.ok && explicit.fields[0]?.required, undefined); + }); + + it('marks secret / sensitive fields', () => { + for (const flag of ['secret', 'sensitive'] as const) { + const out = parseMcpInputRequests([{ name: 'pin', [flag]: true }]); + assert.equal(out.ok && out.fields[0]?.secret, true); + } + }); + + it('MUTATION CHECK: rejects every malformed shape with a distinguishable reason', () => { + const cases: ReadonlyArray = [ + [{ not: 'an array' }, 'not_an_array'], + ['a string', 'not_an_array'], + [null, 'not_an_array'], + [undefined, 'not_an_array'], + [42, 'not_an_array'], + [[], 'empty'], + [Array.from({ length: MCP_INPUT_REQUEST_MAX_FIELDS + 1 }, (_, i) => ({ name: `f${String(i)}` })), 'too_many_fields'], + [[{ label: 'no name at all' }], 'field_without_name'], + [[{ name: ' ' }], 'field_without_name'], + [[{ name: 42 }], 'field_without_name'], + [['just a string'], 'field_without_name'], + [[null], 'field_without_name'], + [[{ name: 'dup' }, { name: 'dup' }], 'duplicate_field_name'], + ]; + for (const [input, reason] of cases) { + const out = parseMcpInputRequests(input); + // Asserting the REASON, not just `ok === false`: a parser that collapsed + // every failure into one code would still pass an `ok`-only assertion + // while making the model-facing error string useless. + assert.equal(out.ok, false, `expected ${JSON.stringify(input)} to be rejected`); + assert.equal(out.ok === false ? out.reason : undefined, reason); + } + }); + + it('truncates over-long strings instead of rejecting the field', () => { + const out = parseMcpInputRequests([{ name: 'x'.repeat(500), description: 'y'.repeat(5_000) }]); + assert.equal(out.ok, true); + assert.ok(out.ok && out.fields[0]!.name.length <= 64); + assert.ok(out.ok && (out.fields[0]!.description?.length ?? 0) <= 500); + }); +}); + +// ── 3. the reply envelope ─────────────────────────────────────────────────── + +describe('mcp input reply envelope (#544 W2-1)', () => { + it('round-trips', () => { + const wire = formatMcpInputReply({ + correlationId: 'corr-9', + inputResponses: { customerNumber: 'K-1234' }, + }); + assert.ok(wire.startsWith(MCP_INPUT_REPLY_PREFIX)); + assert.deepEqual(parseMcpInputReply(wire), { + correlationId: 'corr-9', + inputResponses: { customerNumber: 'K-1234' }, + }); + }); + + it('MUTATION CHECK: an ordinary user message is never mistaken for an envelope', () => { + for (const plain of [ + 'Wie ist das Wetter?', + '', + ' ', + // Deliberately adversarial: the literal prefix typed by a human, and the + // prefix with junk. Both must yield a NORMAL turn — a parser that threw, + // or that returned a truthy record with an empty correlationId, would + // hijack the user's message. + MCP_INPUT_REPLY_PREFIX, + `${MCP_INPUT_REPLY_PREFIX} not json`, + `${MCP_INPUT_REPLY_PREFIX} []`, + `${MCP_INPUT_REPLY_PREFIX} "string"`, + `${MCP_INPUT_REPLY_PREFIX} {}`, + `${MCP_INPUT_REPLY_PREFIX} {"correlationId":""}`, + `${MCP_INPUT_REPLY_PREFIX} {"correlationId":"c"}`, + `${MCP_INPUT_REPLY_PREFIX} {"correlationId":"c","inputResponses":[]}`, + `${MCP_INPUT_REPLY_PREFIX} {"correlationId":"c","inputResponses":null}`, + ]) { + assert.equal(parseMcpInputReply(plain), undefined, `hijacked: ${plain}`); + } + }); + + it('drops non-string response values instead of forwarding them', () => { + const parsed = parseMcpInputReply( + `${MCP_INPUT_REPLY_PREFIX} {"correlationId":"c","inputResponses":{"a":"ok","b":42,"c":{"nested":1}}}`, + ); + assert.deepEqual(parsed?.inputResponses, { a: 'ok' }); + }); +}); + +// ── 4. isInputRequiredResult ──────────────────────────────────────────────── + +describe('SDK 1.29.0 result-schema characterization (#544 W2-1)', () => { + it('passes unmodelled MRTR keys through, so no SDK bump is required', () => { + // Captured from the SHIPPED SDK. `CallToolResultSchema` derives from + // `ResultSchema`, which is `.passthrough()`. If a future SDK tightens that, + // THIS test goes red first and names the cause — instead of MRTR silently + // going dead because `resultType` vanished before `callTool` returned. + const parsed = CallToolResultSchema.parse({ + content: [{ type: 'text', text: 'x' }], + resultType: 'input_required', + inputRequests: [{ name: 'a' }], + }) as Record; + assert.equal(parsed['resultType'], 'input_required'); + assert.deepEqual(parsed['inputRequests'], [{ name: 'a' }]); + }); +}); + +describe('isInputRequiredResult (#544 W2-1)', () => { + it('matches only an exact non-error input_required result', () => { + assert.equal(isInputRequiredResult({ resultType: 'input_required' }), true); + assert.equal(isInputRequiredResult({ resultType: 'input_required', isError: true }), false); + assert.equal(isInputRequiredResult({ resultType: 'INPUT_REQUIRED' }), false); + assert.equal(isInputRequiredResult({ resultType: 'text' }), false); + assert.equal(isInputRequiredResult({}), false); + assert.equal(isInputRequiredResult(null), false); + assert.equal(isInputRequiredResult('input_required'), false); + }); +}); + +// ── 5. integration: a real fake MCP server that asks for input ────────────── + +interface FakeServerHandle { + readonly url: string; + close(): Promise; +} + +/** Per-tool call counter, so "did the manager retry?" is answered by what the + * SERVER saw rather than by counting mock invocations. */ +const serverCalls: string[] = []; +/** Arguments the server received, verbatim, for the replay assertion. */ +const serverArgs: Array> = []; +/** Flipped by a test to make `ask_twice` keep asking. */ +let alwaysAsk = false; + +const INPUT_REQUESTS = [ + { name: 'customerNumber', label: 'Kundennummer', description: 'z. B. K-1234' }, + { name: 'pin', label: 'PIN', secret: true }, +]; + +function buildMcpServerInstance(): McpSdkServer { + const mcp = new McpSdkServer( + { name: 'fake-crm', version: '0.0.0' }, + { capabilities: { tools: {} } }, + ); + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { name: 'create_ticket', inputSchema: { type: 'object' as const } }, + { name: 'ask_twice', inputSchema: { type: 'object' as const } }, + { name: 'malformed_ask', inputSchema: { type: 'object' as const } }, + { name: 'plain', inputSchema: { type: 'object' as const } }, + ], + })); + mcp.setRequestHandler(CallToolRequestSchema, async (request) => { + const name = request.params.name; + const args = (request.params.arguments ?? {}) as Record; + serverCalls.push(name); + serverArgs.push(args); + if (name === 'plain') { + return { content: [{ type: 'text' as const, text: 'just text' }] }; + } + if (name === 'malformed_ask') { + return { + content: [{ type: 'text' as const, text: 'need input' }], + resultType: 'input_required', + // Off-spec on purpose: an object where the array belongs. + inputRequests: { customerNumber: 'Kundennummer' }, + } as never; + } + const answered = typeof args['inputResponses'] === 'object' && args['inputResponses'] !== null; + if (answered && !(name === 'ask_twice' && alwaysAsk)) { + const responses = args['inputResponses'] as Record; + // Echo the collected values back so the test can prove they arrived + // VERBATIM rather than re-serialized or re-masked somewhere en route. + return { + content: [ + { + type: 'text' as const, + text: `Ticket angelegt für ${String(responses['customerNumber'])} (pin=${String(responses['pin'])}, subject=${String(args['subject'])})`, + }, + ], + }; + } + return { + content: [{ type: 'text' as const, text: 'Ich brauche noch Angaben.' }], + resultType: 'input_required', + inputRequests: INPUT_REQUESTS, + message: 'Bitte Kundennummer und PIN angeben.', + } as never; + }); + return mcp; +} + +async function startFakeMcpServer(): Promise { + const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + const mcp = buildMcpServerInstance(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on('close', () => { + void transport.close().catch(() => {}); + void mcp.close().catch(() => {}); + }); + await mcp.connect(transport); + if (req.method === 'POST') { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8'); + await transport.handleRequest(req, res, raw.length > 0 ? JSON.parse(raw) : undefined); + return; + } + await transport.handleRequest(req, res); + }; + const http: HttpServer = createServer((req, res) => { + void handle(req, res).catch(() => { + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + }); + const sockets = new Set(); + http.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => http.listen(0, '127.0.0.1', () => resolve())); + const { port } = http.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/mcp`, + async close() { + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await new Promise((resolve) => http.close(() => resolve())); + }, + }; +} + +const fake = await startFakeMcpServer(); +after(() => fake.close()); + +const CFG: McpServerConfig = { + id: '00000000-0000-4000-8000-00000000f544', + name: 'Kunden-CRM', + transport: 'http', + endpoint: fake.url, +}; + +interface Harness { + readonly store: InMemoryPendingMcpInputStore; + readonly manager: McpManager; + readonly audit: McpCallLogEntry[]; + readonly sidecars: McpSidecarPayload[]; +} + +function harness(): Harness { + const store = new InMemoryPendingMcpInputStore(); + const audit: McpCallLogEntry[] = []; + const sidecars: McpSidecarPayload[] = []; + const manager = new McpManager({ + pendingInput: store, + onToolCall: (e) => audit.push(e), + structuredSink: (p) => sidecars.push(p), + }); + return { store, manager, audit, sidecars }; +} + +/** Claim a card the way the orchestrator does: from the sentinel string. */ +function claimFrom( + h: Harness, + sentinel: string, + owner: { userId: string | null; sessionId: string | null } = OWNER, +): PendingMcpInput | undefined { + return claimMcpInputFromResults(h.store, [sentinel], owner); +} + +/** Run inside a turn so audit attribution has something + * to read — exactly the path production takes. */ +async function inTurn( + turnId: string, + userId: string | undefined, + sessionScope: string | undefined, + fn: () => Promise, +): Promise { + return turnContext.run( + { + turnId, + turnDate: '2026-07-30', + agentSlug: 'main', + ...(userId !== undefined ? { userId } : {}), + ...(sessionScope !== undefined ? { sessionScope } : {}), + }, + fn, + ); +} + +describe('callTool parks an input_required result (#544 W2-1)', () => { + it('reads resultType + inputRequests off the shipped SDK 1.29.0 over a real wire', async () => { + // Criterion 1, end to end: both fields survive `tools/call` on the SDK we + // actually ship, so MRTR needs no version bump and no v2 family (#540). + // + // NOT labelled a mutation check, deliberately: reverting the + // `LENIENT_CALL_TOOL_RESULT_SCHEMA` extension leaves this GREEN, because + // `ResultSchema` is `.passthrough()` (characterized in the test below). The + // extension makes the dependency explicit and typed rather than possible — + // see the schema's doc comment. Claiming otherwise here would be a test + // comment that lies. + const h = harness(); + const out = await inTurn('t-schema', 'u1', 's1', () => + h.manager.callTool(CFG, 'create_ticket', { subject: 'Drucker' }), + ); + assert.ok( + out.startsWith(MCP_INPUT_REQUIRED_SENTINEL_PREFIX), + `resultType/inputRequests were stripped — got: ${out}`, + ); + }); + + it('returns a stable sentinel instead of the rendered text', async () => { + const h = harness(); + const out = await inTurn('t-1', 'u1', 's1', () => + h.manager.callTool(CFG, 'create_ticket', { subject: 'Drucker' }), + ); + assert.ok(out.startsWith(MCP_INPUT_REQUIRED_SENTINEL_PREFIX)); + assert.equal(typeof out, 'string'); + // Names the fields so the model can see what is being collected… + assert.ok(out.includes('customerNumber')); + // …and tells it not to re-call (the model-confusion guard's prose half). + assert.ok(/nicht erneut/i.test(out)); + }); + + it('parks a record reachable only by the full triple', async () => { + const h = harness(); + const sentinel = await inTurn('t-2', 'u1', 's1', () => + h.manager.callTool(CFG, 'create_ticket', { subject: 'X' }), + ); + const pending = claimFrom(h, sentinel); + assert.ok(pending); + assert.equal(pending.serverId, CFG.id); + assert.equal(pending.serverName, 'Kunden-CRM'); + assert.equal(pending.toolName, 'create_ticket'); + assert.deepEqual(pending.originalArgs, { subject: 'X' }); + assert.equal(pending.replayDepth, 0); + assert.equal(pending.prompt, 'Bitte Kundennummer und PIN angeben.'); + assert.deepEqual( + pending.inputRequests.map((f) => f.name), + ['customerNumber', 'pin'], + ); + assert.equal(pending.inputRequests[1]?.secret, true); + // The triple binds it to this caller. + const cid = pending.correlationId; + assert.equal(h.store.take({ userId: 'u1', sessionId: 'other', correlationId: cid }), undefined); + assert.ok(h.store.take({ userId: 'u1', sessionId: 's1', correlationId: cid })); + }); + + it('MUTATION CHECK: audits exactly one row that is neither a success nor a failure', async () => { + const h = harness(); + await inTurn('t-3', 'u1', 's1', () => h.manager.callTool(CFG, 'create_ticket', {})); + assert.equal(h.audit.length, 1); + const row = h.audit[0]!; + // `ok === true` alone would let this pass while the row still LIED about a + // delivered result; `outcome` is the assertion that catches that. Making + // `parkInputRequired` emit `'ok'` or `'fail'` turns this red. + assert.equal(row.outcome, 'input_required'); + assert.equal(row.ok, true, 'a parked call must not pollute failure-rate queries'); + assert.equal(row.error, null); + assert.equal(row.toolName, 'create_ticket'); + assert.equal(row.serverName, 'Kunden-CRM'); + }); + + it('MUTATION CHECK: consumes no retry attempt — the server sees exactly one call', async () => { + const h = harness(); + serverCalls.length = 0; + await inTurn('t-4', 'u1', 's1', () => h.manager.callTool(CFG, 'create_ticket', {})); + // Observed at the SERVER, not by counting a mock: routing the park through + // `handleFailure` (or falling through to `continue`) makes the transient + // retry fire and turns this into 2. + assert.deepEqual(serverCalls, ['create_ticket']); + }); + + it('emits an input_required sidecar carrying the server attribution', async () => { + const h = harness(); + await inTurn('t-5', 'u1', 's1', () => h.manager.callTool(CFG, 'create_ticket', {})); + assert.equal(h.sidecars.length, 1); + const payload = h.sidecars[0]!; + assert.equal(payload.kind, 'input_required'); + if (payload.kind !== 'input_required') return; + assert.equal(payload.turnId, 't-5'); + // Mandatory: the card must be able to say WHO is asking for free text. + assert.equal(payload.pending.serverName, 'Kunden-CRM'); + assert.equal(payload.pending.serverId, CFG.id); + }); + + it('leaves an ordinary success completely unchanged', async () => { + const h = harness(); + const out = await inTurn('t-6', 'u1', 's1', () => h.manager.callTool(CFG, 'plain', {})); + assert.equal(out, 'just text'); + assert.equal(h.audit[0]?.outcome, 'ok'); + assert.equal(h.audit[0]?.ok, true); + assert.equal(h.store.size(), 0); + assert.equal(h.sidecars.length, 0); + }); + + it('MUTATION CHECK: malformed inputRequests degrade to a plain error string', async () => { + const h = harness(); + const out = await inTurn('t-7', 'u1', 's1', () => + h.manager.callTool(CFG, 'malformed_ask', {}), + ); + // A plain tool error — NOT a sentinel, NOT a half-built card. + assert.ok(out.startsWith('Error:'), out); + assert.ok(!out.startsWith(MCP_INPUT_REQUIRED_SENTINEL_PREFIX)); + assert.ok(out.includes('not_an_array')); + assert.equal(h.store.size(), 0, 'nothing may be parked'); + assert.equal(claimFrom(h, out), undefined, 'no card may be claimable'); + assert.equal(h.sidecars.length, 0); + // This one IS a failure and must audit as such. + assert.equal(h.audit[0]?.outcome, 'fail'); + assert.equal(h.audit[0]?.ok, false); + }); + + it('degrades to a plain error when no store is wired', async () => { + const audit: McpCallLogEntry[] = []; + const manager = new McpManager({ onToolCall: (e) => audit.push(e) }); + const out = await inTurn('t-8', 'u1', 's1', () => + manager.callTool(CFG, 'create_ticket', {}), + ); + assert.ok(out.startsWith('Error:'), out); + assert.ok(out.includes('not enabled on this deployment')); + assert.equal(audit[0]?.outcome, 'fail'); + }); + + it('MUTATION CHECK: a second input_required in one turn does not raise a second card', async () => { + const h = harness(); + const first = await inTurn('t-9', 'u1', 's1', () => + h.manager.callTool(CFG, 'create_ticket', { n: 1 }), + ); + const second = await inTurn('t-9', 'u1', 's1', () => + h.manager.callTool(CFG, 'create_ticket', { n: 2 }), + ); + assert.ok(first.startsWith(MCP_INPUT_REQUIRED_SENTINEL_PREFIX)); + assert.ok(second.startsWith(MCP_INPUT_REQUIRED_SENTINEL_PREFIX)); + assert.equal(h.sidecars.length, 2, 'each parked call gets its own sidecar'); + // First-call-wins is enforced where the card is CHOSEN, not where it is + // parked — verified through the payload rather than a counter: the claimed + // record belongs to call #1, and #2 is dropped rather than left orphaned. + const claimed = claimMcpInputFromResults(h.store, [first, second], OWNER); + assert.deepEqual(claimed?.originalArgs, { n: 1 }); + assert.equal(h.store.size(), 1); + }); +}); + +describe('two-turn round trip (#544 W2-1)', () => { + it('MUTATION CHECK: inputResponses reach the server verbatim alongside the original args', async () => { + const h = harness(); + serverArgs.length = 0; + + // ── turn 1: the model calls the tool, the server asks for input. + const sentinel = await inTurn('turn-A', 'u1', 'sess-1', () => + h.manager.callTool(CFG, 'create_ticket', { subject: 'Drucker kaputt' }), + ); + assert.ok(sentinel.startsWith(MCP_INPUT_REQUIRED_SENTINEL_PREFIX)); + const card = claimFrom(h, sentinel, { userId: 'u1', sessionId: 'sess-1' }); + assert.ok(card); + + // ── the user fills the card in; the channel submits the envelope. + const reply = parseMcpInputReply( + formatMcpInputReply({ + correlationId: card.correlationId, + inputResponses: { customerNumber: 'K-1234', pin: '9876' }, + }), + ); + assert.ok(reply); + + // ── turn 2: a DIFFERENT turn resolves the correlation id and replays. + const taken = h.store.take({ + userId: 'u1', + sessionId: 'sess-1', + correlationId: reply.correlationId, + }); + assert.ok(taken); + const out = await inTurn('turn-B', 'u1', 'sess-1', () => + h.manager.callTool(CFG, taken.toolName, { + ...taken.originalArgs, + inputResponses: reply.inputResponses, + }), + ); + + // Asserting the SERVER's view of the arguments, not our own call site: the + // original args survive AND the collected values arrive unmangled. Any + // re-serialization, masking or key-renaming en route turns this red. + const lastArgs = serverArgs.at(-1)!; + assert.deepEqual(lastArgs, { + subject: 'Drucker kaputt', + inputResponses: { customerNumber: 'K-1234', pin: '9876' }, + }); + assert.equal(out, 'Ticket angelegt für K-1234 (pin=9876, subject=Drucker kaputt)'); + assert.equal(h.audit.at(-1)?.outcome, 'ok'); + // Single-use: the correlation id is spent. + assert.equal( + h.store.take({ userId: 'u1', sessionId: 'sess-1', correlationId: reply.correlationId }), + undefined, + ); + }); + + it('MUTATION CHECK: a second input_required on replay is capped, not parked again', async () => { + const h = harness(); + alwaysAsk = true; + try { + const out = await inTurn('turn-C', 'u1', 'sess-1', () => + h.manager.callTool(CFG, 'ask_twice', { + subject: 'X', + inputResponses: { customerNumber: 'K-1' }, + }), + ); + // The ping-pong stops HERE with an error, rather than raising card #2. + // Removing the `replayDepth` derivation (or the store's cap) parks a new + // record and turns this red. + assert.ok(out.startsWith('Error:'), out); + assert.ok(/asked for user input again/.test(out)); + assert.equal(h.store.size(), 0); + assert.equal(claimFrom(h, out), undefined); + assert.equal(h.sidecars.length, 0); + assert.equal(h.audit.at(-1)?.outcome, 'fail'); + } finally { + alwaysAsk = false; + } + }); + + it('a record whose TTL lapsed is simply gone — the replay cannot resurrect it', async () => { + let clock = 0; + const store = new InMemoryPendingMcpInputStore({ ttlMs: 1_000, now: () => clock }); + const manager = new McpManager({ pendingInput: store }); + const sentinel = await inTurn('turn-D', 'u1', 'sess-1', () => + manager.callTool(CFG, 'create_ticket', {}), + ); + const card = claimMcpInputFromResults(store, [sentinel], { + userId: 'u1', + sessionId: 'sess-1', + }); + assert.ok(card); + clock += 1_001; + assert.equal( + store.take({ userId: 'u1', sessionId: 'sess-1', correlationId: card.correlationId }), + undefined, + ); + }); +}); diff --git a/middleware/test/mcpRegistryClient.test.ts b/middleware/test/mcpRegistryClient.test.ts index 32b20208..935e091c 100644 --- a/middleware/test/mcpRegistryClient.test.ts +++ b/middleware/test/mcpRegistryClient.test.ts @@ -278,5 +278,64 @@ describe('McpRegistryClient', () => { const entries = await client.catalog({ ...REGISTRY, id: 'reg-3' }); assert.equal(entries.length, 1); assert.equal(entries[0]?.transport, 'sse'); + // Issue #541: an sse-only entry stays importable (the MCP removal window is + // open) but is flagged so the operator sees what they are signing up for. + assert.equal(entries[0]?.transportDeprecated, true); + }); + + // ── issue #541: deprecated-transport preference on the import path ───────── + // The marketplace importer is the SECOND way an `sse` row can be minted (the + // operator picker is the first), so the deprecation has to bite here too — a + // UI-only change would keep importing legacy SSE servers from catalogs. + describe('deprecated transport preference (#541)', () => { + async function catalogOf(remotes: unknown[]) { + const doc = { servers: [{ name: 'dual', remotes }] }; + const fetchImpl: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes('/v0/servers')) return new Response('nf', { status: 404 }); + return fetchOk(doc)(input, init); + }) as typeof fetch; + const client = new McpRegistryClient({ fetchImpl, log: () => {} }); + return (await client.catalog({ ...REGISTRY, id: 'reg-541' }))[0]; + } + + it('prefers the streamable-http remote when an entry offers sse too', async () => { + const entry = await catalogOf([ + { type: 'sse', url: 'https://x.example/sse' }, + { type: 'streamable-http', url: 'https://x.example/mcp' }, + ]); + assert.equal(entry?.transport, 'http'); + assert.equal(entry?.endpoint, 'https://x.example/mcp'); + assert.equal(entry?.transportDeprecated, false); + }); + + it('still imports an sse-only entry, flagged as deprecated', async () => { + const entry = await catalogOf([{ type: 'sse', url: 'https://x.example/sse' }]); + assert.equal(entry?.transport, 'sse'); + assert.equal(entry?.endpoint, 'https://x.example/sse'); + assert.equal(entry?.transportDeprecated, true); + }); + + it('does not let the preference bypass the untrusted-remote guard', async () => { + // The only http remote is plain-http/internal → refused; the safe sse + // remote is used instead rather than the preference smuggling it through. + const entry = await catalogOf([ + { type: 'sse', url: 'https://x.example/sse' }, + { type: 'streamable-http', url: 'http://169.254.169.254/mcp' }, + ]); + assert.equal(entry?.transport, 'sse'); + assert.equal(entry?.transportDeprecated, true); + }); + + it('marks a stdio entry as non-deprecated', async () => { + const doc = { servers: [{ name: 'local', packages: [{ registry_name: 'npm', name: '@acme/x' }] }] }; + const fetchImpl: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes('/v0/servers')) return new Response('nf', { status: 404 }); + return fetchOk(doc)(input, init); + }) as typeof fetch; + const client = new McpRegistryClient({ fetchImpl, log: () => {} }); + const entry = (await client.catalog({ ...REGISTRY, id: 'reg-541b' }))[0]; + assert.equal(entry?.transport, 'stdio'); + assert.equal(entry?.transportDeprecated, false); + }); }); }); diff --git a/middleware/test/mcpRegistrySchema.pg.test.ts b/middleware/test/mcpRegistrySchema.pg.test.ts new file mode 100644 index 00000000..7f671814 --- /dev/null +++ b/middleware/test/mcpRegistrySchema.pg.test.ts @@ -0,0 +1,376 @@ +import { strict as assert } from 'node:assert'; +import { readFile, readdir } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { after, before, describe, it } from 'node:test'; + +import { Pool, type PoolClient } from 'pg'; + +import { runMultiOrchestratorMigrations } from '@omadia/orchestrator'; + +/** + * PG-gated coverage for the MCP registry / OAuth schema in + * `middleware/migrations` (0010 registries, 0013 registry kinds, 0014 the + * top-level grant unique index, 0015/0016 OAuth 2.1 + PKCE). + * + * Context (W0-4): `middleware/migrations` was absent from the CI `schema` + * job's MIGRATION_DOMAINS, so every MCP migration shipped without ever being + * applied or idempotency-checked in CI, and no pg test covered MCP at all. + * Adding the domain closes the apply/re-apply gap; this file closes the + * behavioural gap and adds the one check CI structurally cannot make — the + * CI gate re-applies against an EMPTY database, which cannot catch a + * migration that is only non-idempotent once rows exist. + * + * Isolation: every row this suite writes carries the `w04-mcp-` tenant + * prefix so it cannot collide with the other pg suites sharing the database. + * The destructive re-apply check needs more than a prefix — re-running + * 0001/0003 drops and recreates the NOTIFY triggers, taking ACCESS EXCLUSIVE + * on shared tables — so it runs against its own schema with `public` off the + * search_path. A scratch *database* would also isolate it, but CREATE/DROP + * DATABASE is a cluster-wide operation: it stalled the other pg suites running + * concurrently for long enough to cancel 29 of their tests. + * Skips when no test Postgres is reachable, mirroring the other pg tests. + */ +const PG_URL = + process.env['GRAPH_PG_TEST_URL'] ?? + process.env['MEMORY_PG_TEST_URL'] ?? + process.env['WS5_PG_TEST_URL'] ?? + 'postgres://test:test@127.0.0.1:55438/test'; + +/** Tenant prefix — unique to this suite, see the isolation note above. */ +const TENANT = 'w04-mcp-'; +/** Schema the re-apply check builds its own copy of the domain in. */ +const REAPPLY_SCHEMA = 'w04_mcp_reapply'; + +const migrationsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'migrations'); + +/** + * One capped pool for the whole file. The test runner executes files + * concurrently and ~16 other pg suites each hold a default-sized (max 10) + * pool, so an uncapped extra pool here is enough to exhaust + * `max_connections` and cancel an unrelated suite mid-flight. + */ +const probePool = new Pool({ + connectionString: PG_URL, + connectionTimeoutMillis: 2000, + max: 2, + idleTimeoutMillis: 1000, +}); +let pgAvailable = true; +try { + await probePool.query('SELECT 1'); +} catch { + pgAvailable = false; + await probePool.end().catch(() => undefined); +} + +async function migrationFiles(): Promise { + return (await readdir(migrationsDir)).filter((f) => f.endsWith('.sql')).sort(); +} + +/** Postgres error code for a statement that violated a constraint we assert on. */ +async function expectRejected(pool: Pool, sql: string, params: readonly unknown[] = []) { + try { + await pool.query(sql, [...params]); + } catch (err: unknown) { + return err as { code?: string }; + } + assert.fail(`expected the statement to be rejected: ${sql}`); +} + +describe('MCP registry + OAuth schema (pg)', { skip: !pgAvailable }, () => { + const pool = probePool; + + async function cleanup(): Promise { + // mcp_servers cascades to oauth tokens/flows and to agent_tool_grants; + // agents cascades to its grants. Registries are referenced by + // mcp_servers.registry_id with ON DELETE SET NULL, so order matters only + // for readability here. + await pool.query('DELETE FROM mcp_servers WHERE name LIKE $1', [`${TENANT}%`]); + await pool.query('DELETE FROM agents WHERE slug LIKE $1', [`${TENANT}%`]); + await pool.query('DELETE FROM mcp_registries WHERE name LIKE $1', [`${TENANT}%`]); + await pool.query('DELETE FROM mcp_oauth_clients WHERE issuer LIKE $1', [`${TENANT}%`]); + } + + before(async () => { + await runMultiOrchestratorMigrations(pool, undefined, migrationsDir); + await cleanup(); + }); + + // The pool is shared with the re-apply suite below, so it is closed by the + // file-level `after` hook rather than here. + after(cleanup); + + it('seeds the official and smithery registries with their catalog kinds (0010 + 0013)', async () => { + const { rows } = await pool.query<{ name: string; kind: string; auth_kind: string }>( + `SELECT name, kind, auth_kind FROM mcp_registries + WHERE name IN ('official', 'smithery') ORDER BY name`, + ); + + // 0010 seeds `official` before 0013 adds `kind`; 0013's UPDATE is what + // lifts it off the 'generic' column default. A silent no-op there would + // leave the official registry using the wrong catalog normalizer. + assert.deepEqual( + rows.map((r) => [r.name, r.kind, r.auth_kind]), + [ + ['official', 'official', 'none'], + ['smithery', 'smithery', 'none'], + ], + ); + }); + + it('constrains registry kind and auth_kind to the known sets (0010 + 0013)', async () => { + const badKind = await expectRejected( + pool, + `INSERT INTO mcp_registries (name, url, auth_kind, kind) + VALUES ($1, 'https://reg.invalid', 'none', 'not-a-kind')`, + [`${TENANT}bad-kind`], + ); + assert.equal(badKind.code, '23514', 'unknown registry kind must fail the CHECK'); + + const badAuth = await expectRejected( + pool, + `INSERT INTO mcp_registries (name, url, auth_kind) + VALUES ($1, 'https://reg.invalid', 'basic')`, + [`${TENANT}bad-auth`], + ); + assert.equal(badAuth.code, '23514', 'unknown registry auth_kind must fail the CHECK'); + }); + + it('defaults marketplace provenance to manual and detaches on registry delete (0010)', async () => { + const registry = await pool.query<{ id: string }>( + `INSERT INTO mcp_registries (name, url, auth_kind, kind) + VALUES ($1, 'https://reg.invalid', 'none', 'generic') RETURNING id`, + [`${TENANT}registry`], + ); + const registryId = registry.rows[0]!.id; + + const server = await pool.query<{ id: string; source: string; registry_id: string | null }>( + `INSERT INTO mcp_servers (name, transport, endpoint) + VALUES ($1, 'http', 'https://srv.invalid/mcp') + RETURNING id, source, registry_id`, + [`${TENANT}server`], + ); + assert.equal(server.rows[0]!.source, 'manual', 'pre-marketplace rows are implicitly manual'); + assert.equal(server.rows[0]!.registry_id, null); + + const badSource = await expectRejected( + pool, + `UPDATE mcp_servers SET source = 'somewhere-else' WHERE id = $1`, + [server.rows[0]!.id], + ); + assert.equal(badSource.code, '23514', 'source is restricted to manual|marketplace'); + + await pool.query(`UPDATE mcp_servers SET source = 'marketplace', registry_id = $1 WHERE id = $2`, [ + registryId, + server.rows[0]!.id, + ]); + + // Deleting a catalog source must orphan the imported server, not delete it. + await pool.query('DELETE FROM mcp_registries WHERE id = $1', [registryId]); + const after = await pool.query<{ source: string; registry_id: string | null }>( + 'SELECT source, registry_id FROM mcp_servers WHERE id = $1', + [server.rows[0]!.id], + ); + assert.equal(after.rows[0]!.registry_id, null, 'ON DELETE SET NULL must keep the server row'); + assert.equal(after.rows[0]!.source, 'marketplace'); + }); + + it('rejects a duplicate top-level MCP grant but not the sub-agent equivalent (0014)', async () => { + const agent = await pool.query<{ id: string }>( + `INSERT INTO agents (slug, name) VALUES ($1, 'W0-4 MCP Agent') RETURNING id`, + [`${TENANT}agent`], + ); + const server = await pool.query<{ id: string }>( + `INSERT INTO mcp_servers (name, transport, endpoint) + VALUES ($1, 'http', 'https://srv.invalid/mcp') RETURNING id`, + [`${TENANT}grant-server`], + ); + const agentId = agent.rows[0]!.id; + const serverId = server.rows[0]!.id; + const toolRef = `${TENANT}grant-server:ping`; + + const insertTopLevel = `INSERT INTO agent_tool_grants (agent_id, tool_kind, tool_ref, mcp_server_id) + VALUES ($1, 'mcp', $2, $3)`; + await pool.query(insertTopLevel, [agentId, toolRef, serverId]); + + const dup = await expectRejected(pool, insertTopLevel, [agentId, toolRef, serverId]); + assert.equal(dup.code, '23505', 'the partial unique index must block a repeat grant'); + + // The index is scoped to `agent_id IS NOT NULL AND tool_kind = 'mcp'`: + // a native grant on the same agent must stay unaffected. + await pool.query( + `INSERT INTO agent_tool_grants (agent_id, tool_kind, tool_ref) VALUES ($1, 'native', $2)`, + [agentId, toolRef], + ); + await pool.query( + `INSERT INTO agent_tool_grants (agent_id, tool_kind, tool_ref) VALUES ($1, 'native', $2)`, + [agentId, toolRef], + ); + + const { rows } = await pool.query<{ count: string }>( + 'SELECT count(*)::text AS count FROM agent_tool_grants WHERE agent_id = $1', + [agentId], + ); + assert.equal(rows[0]!.count, '3', 'one mcp grant plus two unconstrained native grants'); + }); + + it('binds an OAuth flow to its authorize-time endpoints and cascades on server delete (0015 + 0016)', async () => { + const server = await pool.query<{ id: string }>( + `INSERT INTO mcp_servers (name, transport, endpoint) + VALUES ($1, 'http', 'https://oauth.invalid/mcp') RETURNING id`, + [`${TENANT}oauth-server`], + ); + const serverId = server.rows[0]!.id; + const issuer = `${TENANT}https://issuer.invalid`; + + await pool.query( + `INSERT INTO mcp_oauth_clients (issuer, client_id, registered_via) VALUES ($1, 'cid', 'dcr')`, + [issuer], + ); + const badRegistration = await expectRejected( + pool, + `INSERT INTO mcp_oauth_clients (issuer, client_id, registered_via) VALUES ($1, 'cid', 'guessed')`, + [`${TENANT}https://issuer2.invalid`], + ); + assert.equal(badRegistration.code, '23514', 'registered_via is restricted to dcr|manual'); + + await pool.query( + `INSERT INTO mcp_oauth_tokens (server_id, user_key, access_token_ref) + VALUES ($1, $2, 'vault://access')`, + [serverId, `${TENANT}user`], + ); + // 0016: the token endpoint is persisted at authorize time so the callback + // cannot be redirected to a re-discovered (attacker-swapped) endpoint. + await pool.query( + `INSERT INTO mcp_oauth_flows + (state, server_id, user_key, issuer, code_verifier, redirect_uri, + token_endpoint, authorization_endpoint) + VALUES ($1, $2, $3, $4, 'verifier', 'https://cb.invalid', + 'https://issuer.invalid/token', 'https://issuer.invalid/authorize')`, + [`${TENANT}state`, serverId, `${TENANT}user`, issuer], + ); + + const flow = await pool.query<{ token_endpoint: string; authorization_endpoint: string }>( + 'SELECT token_endpoint, authorization_endpoint FROM mcp_oauth_flows WHERE state = $1', + [`${TENANT}state`], + ); + assert.equal(flow.rows[0]!.token_endpoint, 'https://issuer.invalid/token'); + assert.equal(flow.rows[0]!.authorization_endpoint, 'https://issuer.invalid/authorize'); + + // Deleting the server must not leave live credentials or pending flows behind. + await pool.query('DELETE FROM mcp_servers WHERE id = $1', [serverId]); + const tokens = await pool.query('SELECT 1 FROM mcp_oauth_tokens WHERE server_id = $1', [serverId]); + const flows = await pool.query('SELECT 1 FROM mcp_oauth_flows WHERE server_id = $1', [serverId]); + assert.equal(tokens.rowCount, 0, 'tokens must cascade with the server'); + assert.equal(flows.rowCount, 0, 'pending flows must cascade with the server'); + }); +}); + +describe('middleware/migrations idempotency under data (pg)', { skip: !pgAvailable }, () => { + /** + * Runs against a dedicated schema on a single pinned connection, with + * `public` deliberately absent from the search_path: the migrations name + * every object unqualified, so they build a private copy of the domain here + * and never touch — or lock — the shared tables the other pg suites use. + */ + let client: PoolClient | undefined; + + before(async () => { + client = await probePool.connect(); + await client.query(`DROP SCHEMA IF EXISTS ${REAPPLY_SCHEMA} CASCADE`); + await client.query(`CREATE SCHEMA ${REAPPLY_SCHEMA}`); + await client.query(`SET search_path = ${REAPPLY_SCHEMA}`); + }); + + after(async () => { + if (!client) return; + await client.query('RESET search_path').catch(() => undefined); + await client.query(`DROP SCHEMA IF EXISTS ${REAPPLY_SCHEMA} CASCADE`).catch(() => undefined); + client.release(); + }); + + it('re-applies every migration cleanly with rows present', async () => { + const pool = client!; + const files = await migrationFiles(); + assert.ok(files.length > 0, 'expected migrations to be discovered'); + + // Pass 1 — virgin schema. `middleware/migrations` needs no extensions + // (gen_random_uuid is core since pg13) and has no cross-domain FKs, which + // is why this domain can be applied standalone. + for (const file of files) { + await pool.query(await readFile(join(migrationsDir, file), 'utf8')); + } + + // Guard the isolation itself: if search_path had leaked to `public` the + // migrations would have been no-ops against the shared tables and every + // assertion below would pass vacuously. + const built = await pool.query<{ count: string }>( + `SELECT count(*)::text AS count FROM information_schema.tables WHERE table_schema = $1`, + [REAPPLY_SCHEMA], + ); + assert.ok( + Number(built.rows[0]!.count) > 25, + `expected the domain to be built inside ${REAPPLY_SCHEMA}, saw ${built.rows[0]!.count} tables`, + ); + + // Seed the MCP + agent surfaces so the re-apply runs against real + // rows — the case the CI gate cannot reach, since it re-applies empty. + const server = await pool.query<{ id: string }>( + `INSERT INTO mcp_servers (name, transport, endpoint) + VALUES ($1, 'http', 'https://srv.invalid/mcp') RETURNING id`, + [`${TENANT}reapply-server`], + ); + const agent = await pool.query<{ id: string }>( + `INSERT INTO agents (slug, name) VALUES ($1, 'W0-4 Scratch') RETURNING id`, + [`${TENANT}reapply-agent`], + ); + await pool.query( + `INSERT INTO mcp_registries (name, url, auth_kind, kind) + VALUES ($1, 'https://reg.invalid', 'none', 'generic')`, + [`${TENANT}reapply-registry`], + ); + await pool.query( + `INSERT INTO agent_tool_grants (agent_id, tool_kind, tool_ref, mcp_server_id) + VALUES ($1, 'mcp', $2, $3)`, + [agent.rows[0]!.id, `${TENANT}reapply-server:ping`, server.rows[0]!.id], + ); + await pool.query( + `INSERT INTO mcp_oauth_clients (issuer, client_id, registered_via) + VALUES ($1, 'cid', 'manual')`, + [`${TENANT}https://reapply-issuer.invalid`], + ); + await pool.query( + `INSERT INTO mcp_oauth_tokens (server_id, user_key, access_token_ref) + VALUES ($1, $2, 'vault://access')`, + [server.rows[0]!.id, `${TENANT}user`], + ); + + // Pass 2 — the CI idempotency gate, but with the rows above in place. + for (const file of files) { + await pool.query(await readFile(join(migrationsDir, file), 'utf8')); + } + + // The seeded rows must survive, and the seed INSERTs in 0010/0013 must not + // have duplicated their registries. + const registries = await pool.query<{ name: string }>( + `SELECT name FROM mcp_registries WHERE name IN ('official', 'smithery')`, + ); + assert.equal(registries.rowCount, 2, 'ON CONFLICT DO NOTHING keeps the seed rows unique'); + + const grants = await pool.query('SELECT 1 FROM agent_tool_grants WHERE agent_id = $1', [ + agent.rows[0]!.id, + ]); + assert.equal(grants.rowCount, 1, 're-applying must not drop or duplicate existing grants'); + + const tokens = await pool.query('SELECT 1 FROM mcp_oauth_tokens WHERE server_id = $1', [ + server.rows[0]!.id, + ]); + assert.equal(tokens.rowCount, 1, 're-applying must not disturb stored OAuth token refs'); + }); +}); + +// Both suites share the single capped pool, so it is closed once, here. +after(async () => { + if (pgAvailable) await probePool.end().catch(() => undefined); +}); diff --git a/middleware/test/mcpStructuredContent.test.ts b/middleware/test/mcpStructuredContent.test.ts new file mode 100644 index 00000000..4b797f87 --- /dev/null +++ b/middleware/test/mcpStructuredContent.test.ts @@ -0,0 +1,451 @@ +/** + * Issue #547 (W1-3) — MCP structured-content sidecar + outputSchema capture. + * + * Three things are locked down here: + * + * 1. A GOLDEN/characterization suite for `renderToolResult`. The model-facing + * string is the contract every downstream hop (Privacy Shield, KG ingest, + * session log, the LLM itself) depends on. These goldens were captured from + * the pre-change implementation; any diff in them is a behaviour change, not + * a test that needs updating. + * 2. `extractStructured` — the new, SEPARATE reader for `structuredContent`. + * 3. The out-of-band sink: installing one must not move a single byte of the + * model-facing string (the mutation check below), and the payload must be + * the parsed object the server sent, not a re-parse of the rendered text. + * + * Lives in its own file (not `mcpClient.test.ts`) to stay merge-conflict-free + * with the parallel unit creating that file. + */ + +import { after, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { Server as McpSdkServer } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import { + McpManager, + extractStructured, + mcpNativeHandler, + renderToolResult, + turnContext, + type McpServerConfig, + type McpSidecarPayload, + type McpToolDescriptor, +} from '@omadia/orchestrator'; + +// ── 1. renderToolResult golden suite ──────────────────────────────────────── + +/** Captured from the implementation BEFORE the sidecar work. Each entry is + * [label, result, exact expected string]. */ +const RENDER_GOLDENS: ReadonlyArray = [ + ['text-only', { content: [{ type: 'text', text: 'hello world' }] }, 'hello world'], + [ + 'mixed blocks (text + resource + unknown block)', + { + content: [ + { type: 'text', text: 'line one' }, + { type: 'resource', resource: { uri: 'file:///a.txt', text: 'from resource' } }, + { type: 'image', data: 'AAAA', mimeType: 'image/png' }, + ], + }, + 'line one\nfrom resource\n{"type":"image","data":"AAAA","mimeType":"image/png"}', + ], + [ + 'structuredContent-only (no content array)', + { structuredContent: { city: 'Berlin', tempC: 21 } }, + '{"city":"Berlin","tempC":21}', + ], + ['empty content array', { content: [] }, '[]'], + [ + // The content-array branch wins even when structuredContent is present — + // an empty array renders as "[]", NOT as the structured payload. + 'empty content array + structuredContent', + { content: [], structuredContent: { a: 1 } }, + '[]', + ], + [ + // Off-spec: the MCP spec requires an object here, but hosted proxies (e.g. + // the Strava proxy) send arrays. This is exactly why the lenient schema + // exists — the string form must stay stable for those servers too. + 'array-valued structuredContent', + { structuredContent: [{ id: 1 }, { id: 2 }] }, + '[{"id":1},{"id":2}]', + ], + [ + 'content AND structuredContent — content wins', + { content: [{ type: 'text', text: 'Weather: 21C' }], structuredContent: { tempC: 21 } }, + 'Weather: 21C', + ], + ['isError result', { content: [{ type: 'text', text: 'boom' }], isError: true }, 'Error: boom'], + [ + 'whitespace-only text falls back to the raw content JSON', + { content: [{ type: 'text', text: ' ' }] }, + '[{"type":"text","text":" "}]', + ], + ['null result', null, '{}'], + ['undefined result', undefined, '{}'], + ['empty object result', {}, '{}'], +]; + +describe('renderToolResult golden suite (#547 W1-3 characterization)', () => { + for (const [label, res, expected] of RENDER_GOLDENS) { + it(`renders ${label} byte-identically`, () => { + assert.equal(renderToolResult(res), expected); + }); + } + + it('never returns a non-string', () => { + for (const [, res] of RENDER_GOLDENS) { + assert.equal(typeof renderToolResult(res), 'string'); + } + }); +}); + +// ── 2. extractStructured ──────────────────────────────────────────────────── + +describe('extractStructured (#547 W1-3)', () => { + it('returns an object payload as-is', () => { + const payload = { city: 'Berlin', tempC: 21 }; + const out = extractStructured({ content: [], structuredContent: payload }); + assert.deepEqual(out, payload); + }); + + it('preserves object identity — it is the parsed payload, not a re-parse', () => { + const payload = { nested: { deep: true } }; + const out = extractStructured({ structuredContent: payload }); + assert.equal(out, payload); + }); + + it('returns an off-spec array payload unnormalised', () => { + const out = extractStructured({ structuredContent: [{ id: 1 }, { id: 2 }] }); + assert.deepEqual(out, [{ id: 1 }, { id: 2 }]); + assert.ok(Array.isArray(out)); + }); + + it('returns a scalar payload as-is', () => { + assert.equal(extractStructured({ structuredContent: 42 }), 42); + assert.equal(extractStructured({ structuredContent: 'plain' }), 'plain'); + assert.equal(extractStructured({ structuredContent: false }), false); + }); + + it('folds an explicit null payload into "absent"', () => { + assert.equal(extractStructured({ structuredContent: null }), undefined); + }); + + it('returns undefined when structuredContent is absent', () => { + assert.equal(extractStructured({ content: [{ type: 'text', text: 'hi' }] }), undefined); + assert.equal(extractStructured({}), undefined); + }); + + it('returns undefined on an isError result even when a payload is present', () => { + assert.equal( + extractStructured({ content: [], isError: true, structuredContent: { e: 1 } }), + undefined, + ); + }); + + it('returns undefined for non-object results', () => { + assert.equal(extractStructured(null), undefined); + assert.equal(extractStructured(undefined), undefined); + assert.equal(extractStructured('a string'), undefined); + assert.equal(extractStructured(7), undefined); + }); +}); + +// ── 3. listTools outputSchema capture ─────────────────────────────────────── + +const OBJECT_SCHEMA = { + type: 'object', + properties: { tempC: { type: 'number' } }, + required: ['tempC'], +} as const; + +const STUB_CFG: McpServerConfig = { + id: '00000000-0000-4000-8000-00000000f001', + name: 'stub-server', + transport: 'http', + endpoint: 'http://127.0.0.1:9/mcp', +}; + +/** Swap the manager's private connect step for a canned `tools/list` reply. + * Necessary because the SDK's client-side result schema rejects a malformed + * `outputSchema` outright, so the "non-object" case can't be exercised over a + * real wire — but the mapping still has to drop it rather than propagate it. */ +function managerWithToolList(tools: readonly unknown[]): McpManager { + const manager = new McpManager(); + ( + manager as unknown as { + getOrConnect: () => Promise<{ client: { listTools: () => Promise } }>; + } + ).getOrConnect = async () => ({ client: { listTools: async () => ({ tools }) } }); + return manager; +} + +describe('McpManager.listTools outputSchema capture (#547 W1-3)', () => { + it('copies outputSchema when the server declares one', async () => { + const manager = managerWithToolList([ + { name: 'get_weather', description: 'w', inputSchema: { type: 'object' }, outputSchema: OBJECT_SCHEMA }, + ]); + const [tool] = await manager.listTools(STUB_CFG); + assert.deepEqual(tool?.outputSchema, OBJECT_SCHEMA); + // The pre-existing fields must be untouched. + assert.equal(tool?.name, 'get_weather'); + assert.equal(tool?.description, 'w'); + assert.deepEqual(tool?.inputSchema, { type: 'object' }); + }); + + it('omits the key entirely when the server declares no outputSchema', async () => { + const manager = managerWithToolList([{ name: 'ping', inputSchema: { type: 'object' } }]); + const [tool] = await manager.listTools(STUB_CFG); + assert.equal(tool?.outputSchema, undefined); + assert.equal('outputSchema' in (tool as McpToolDescriptor), false); + }); + + it('drops a non-object outputSchema instead of propagating it', async () => { + for (const bad of ['a string', 42, true, null, [{ type: 'object' }]]) { + const manager = managerWithToolList([{ name: 'weird', outputSchema: bad }]); + const [tool] = await manager.listTools(STUB_CFG); + assert.equal(tool?.outputSchema, undefined, `expected ${JSON.stringify(bad)} to be dropped`); + } + }); +}); + +// ── 4. Integration: a real fake MCP server over streamable HTTP ───────────── + +interface FakeServerHandle { + readonly url: string; + close(): Promise; +} + +/** Build one MCP server instance. Stateless mode (`sessionIdGenerator: + * undefined`) + a fresh instance per request, so each test can connect its own + * `McpManager` without tripping "Server already initialized". */ +function buildMcpServerInstance(): McpSdkServer { + const mcp = new McpSdkServer( + { name: 'fake-weather', version: '0.0.0' }, + { capabilities: { tools: {} } }, + ); + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'get_weather', + description: 'Current weather.', + inputSchema: { type: 'object' as const, properties: {}, required: [] }, + outputSchema: OBJECT_SCHEMA, + }, + { name: 'plain_note', description: 'Text only.', inputSchema: { type: 'object' as const } }, + ], + })); + mcp.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'plain_note') { + return { content: [{ type: 'text' as const, text: 'just text' }] }; + } + // BOTH a text block and a structuredContent payload, with different + // contents: a sink payload derived from the rendered string would be + // detectably wrong (the text is prose that is not even valid JSON). + return { + content: [{ type: 'text' as const, text: 'Weather: 21C in Berlin' }], + structuredContent: { tempC: 21, city: 'Berlin' }, + }; + }); + return mcp; +} + +/** Minimal in-process MCP server over streamable HTTP on an ephemeral port. */ +async function startFakeMcpServer(): Promise { + const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + const mcp = buildMcpServerInstance(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on('close', () => { + void transport.close().catch(() => {}); + void mcp.close().catch(() => {}); + }); + await mcp.connect(transport); + if (req.method === 'POST') { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8'); + await transport.handleRequest(req, res, raw.length > 0 ? JSON.parse(raw) : undefined); + return; + } + await transport.handleRequest(req, res); + }; + const http: HttpServer = createServer((req, res) => { + void handle(req, res).catch(() => { + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + }); + // The MCP client pools its connections and never closes them, so keep-alive + // sockets would keep `http.close()` (and the test runner's event loop) + // waiting forever. Track and destroy them explicitly on teardown. + const sockets = new Set(); + http.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => http.listen(0, '127.0.0.1', () => resolve())); + const { port } = http.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/mcp`, + async close() { + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await new Promise((resolve) => http.close(() => resolve())); + }, + }; +} + +const fake = await startFakeMcpServer(); +after(() => fake.close()); + +const FAKE_CFG: McpServerConfig = { + id: '00000000-0000-4000-8000-00000000f002', + name: 'fake-weather', + transport: 'http', + endpoint: fake.url, +}; + +describe('structured-content sidecar over a real MCP connection (#547 W1-3)', () => { + it('discovers the declared outputSchema', async () => { + const manager = new McpManager(); + const tools = await manager.listTools(FAKE_CFG); + const weather = tools.find((t) => t.name === 'get_weather'); + const note = tools.find((t) => t.name === 'plain_note'); + assert.deepEqual(weather?.outputSchema, OBJECT_SCHEMA); + assert.equal(note?.outputSchema, undefined); + }); + + it('leaves tool_result content unchanged and hands the parsed object to the sink', async () => { + const seen: McpSidecarPayload[] = []; + const manager = new McpManager({ structuredSink: (p) => seen.push(p) }); + // Discovery first so the sidecar can attach the declared schema. + await manager.listTools(FAKE_CFG); + + const result = await manager.callTool(FAKE_CFG, 'get_weather', {}); + + // The model-facing string is the TEXT block, untouched by the payload. + assert.equal(result, 'Weather: 21C in Berlin'); + assert.equal(typeof result, 'string'); + + assert.equal(seen.length, 1); + const payload = seen[0]!; + assert.equal(payload.kind, 'structured_output'); + assert.equal(payload.serverId, FAKE_CFG.id); + assert.equal(payload.toolName, 'get_weather'); + // Identity, not a re-parse of the rendered string: the rendered string is + // prose that would not parse as JSON at all. + assert.deepEqual(payload.structured, { tempC: 21, city: 'Berlin' }); + assert.throws(() => JSON.parse(result)); + assert.deepEqual(payload.outputSchema, OBJECT_SCHEMA); + }); + + it('carries the turn id when called inside a turn', async () => { + const seen: McpSidecarPayload[] = []; + const manager = new McpManager({ structuredSink: (p) => seen.push(p) }); + await turnContext.run( + { turnId: 'turn-547', turnDate: '2026-07-30', agentSlug: 'main' }, + () => manager.callTool(FAKE_CFG, 'get_weather', {}), + ); + assert.equal(seen[0]?.turnId, 'turn-547'); + }); + + it('emits nothing for a tool that returns no structuredContent', async () => { + const seen: McpSidecarPayload[] = []; + const manager = new McpManager({ structuredSink: (p) => seen.push(p) }); + const result = await manager.callTool(FAKE_CFG, 'plain_note', {}); + assert.equal(result, 'just text'); + assert.equal(seen.length, 0); + }); + + it('emits nothing when the call fails', async () => { + const seen: McpSidecarPayload[] = []; + const manager = new McpManager({ structuredSink: (p) => seen.push(p) }); + const dead: McpServerConfig = { + id: '00000000-0000-4000-8000-00000000f003', + name: 'dead-server', + transport: 'http', + endpoint: 'http://127.0.0.1:9/mcp', + }; + const result = await manager.callTool(dead, 'get_weather', {}); + assert.ok(result.startsWith('Error:')); + assert.equal(seen.length, 0); + }); + + it('survives a throwing sink without affecting the tool call', async () => { + const manager = new McpManager({ + structuredSink: () => { + throw new Error('sink exploded'); + }, + }); + const result = await manager.callTool(FAKE_CFG, 'get_weather', {}); + assert.equal(result, 'Weather: 21C in Berlin'); + }); + + // ── MUTATION CHECK ──────────────────────────────────────────────────────── + // Counting sink invocations proves nothing about isolation. This proves it: + // a hostile sink that rewrites its payload — including the nested object it + // was handed — must not move a byte of the LLM-bound string. + it('MUTATION CHECK: a sink that returns and writes a DIFFERENT object cannot change the LLM-bound message', async () => { + const baseline = await new McpManager().callTool(FAKE_CFG, 'get_weather', {}); + + const hostile = new McpManager({ + structuredSink: ((payload: McpSidecarPayload) => { + // Rewrite every field of the payload we were handed... + const mutable = payload as unknown as Record; + mutable['toolName'] = 'TAMPERED'; + mutable['serverId'] = 'TAMPERED'; + mutable['structured'] = { hijacked: true, tempC: -999 }; + mutable['outputSchema'] = { type: 'object', properties: { hijacked: {} } }; + // ...deep-mutate the nested payload object too, in case anything + // downstream still holds the original reference... + const structured = payload.structured as Record | undefined; + if (structured && typeof structured === 'object') { + structured['city'] = 'TAMPERED'; + structured['tempC'] = -999; + } + // ...and hand back a completely different object as the return value. + return { totally: 'different' }; + }) as unknown as (payload: McpSidecarPayload) => void, + }); + const withHostileSink = await hostile.callTool(FAKE_CFG, 'get_weather', {}); + + assert.equal( + withHostileSink, + baseline, + 'installing a sink changed the model-facing string — the channel is NOT out-of-band', + ); + assert.equal(withHostileSink, 'Weather: 21C in Berlin'); + // A second call through the same (already-tampered-with) manager must be + // just as clean — no state leaked from the sink back into the manager. + assert.equal(await hostile.callTool(FAKE_CFG, 'get_weather', {}), baseline); + }); + + // ── PRIVACY SHIELD ──────────────────────────────────────────────────────── + // orchestrator.dispatchTool gates BOTH `captureRawToolResult` and Privacy + // Shield masking on `typeof result === 'string'`. A non-string result would + // silently skip masking, i.e. bypass the shield. Assert the value that + // actually reaches that branch — the NativeToolHandler's return — is still a + // string with a sink installed. + it('PRIVACY SHIELD: the value reaching the orchestrator masking branch is still a string', async () => { + const seen: McpSidecarPayload[] = []; + const manager = new McpManager({ structuredSink: (p) => seen.push(p) }); + const handler = mcpNativeHandler(manager, FAKE_CFG, 'get_weather'); + const result: unknown = await handler({}); + assert.equal(typeof result, 'string', 'a non-string here bypasses Privacy Shield entirely'); + assert.equal(result, 'Weather: 21C in Berlin'); + // Sanity: the sidecar did fire, so this is not a vacuous pass. + assert.equal(seen.length, 1); + }); +}); diff --git a/middleware/test/mcpStructuredOutputPrivacy.test.ts b/middleware/test/mcpStructuredOutputPrivacy.test.ts new file mode 100644 index 00000000..04ab0571 --- /dev/null +++ b/middleware/test/mcpStructuredOutputPrivacy.test.ts @@ -0,0 +1,427 @@ +/** + * Issue #547 (W5-2) — WHY the structured-output sidecar is NOT wired onward. + * + * #547 landed the producer (`McpManager.structuredSink`) as plumbing only. The + * obvious next step is to wire that sink through the orchestrator onto the + * terminal `done` stream event so the UI can render a card. This file pins the + * property that decides whether that is safe: the sidecar never crosses the + * interning seam, so it still carries exactly what the server sent. It is also + * a regression guard — if someone later makes the sidecar mask, the sidecar + * test below turns red and this file must be revisited on purpose. + * + * WHICH BOUNDARY THIS IS ABOUT — read this before calling anything a leak. + * Privacy Shield v4's data-plane boundary is server <-> LLM PROVIDER, not + * server <-> browser. The browser sits on the TRUSTED side and legitimately + * receives real values: that is precisely what `takeRenderedAnswerV4` hands + * back (`plugin-api/src/privacyReceipt.ts:164-174`), rendered with + * `highlightTerms={message.maskedValues}` in the chat page. So "the sidecar is + * not interned" is NOT by itself a leak to the browser, and this file + * deliberately does not call it one. What it IS: an asymmetry that makes the + * sidecar unsafe to forward across the MODEL boundary, and unsafe for any + * future consumer to assume masked. An earlier revision of this file named one + * test `LEAK` and described the interned text as what "a client receives"; + * both encoded a misreading of which side the browser is on. + * + * The asymmetry, stated exactly: + * + * - A tool's TEXT result is interned at the dispatch seam. `dispatchTool` + * returns `internToolResultV4(...).digestText`, so the string the MODEL + * sees is a digest, not the rows. That same return value is what the + * client-facing `tool_result` stream event carries + * (`orchestrator.ts:5330` builds the slot promise from `dispatchTool`; + * `:4934` resolves it; `:4977` puts it on the wire as `output`). + * + * - The STRUCTURED payload is emitted from inside `McpManager.callTool` + * (`mcpClient.ts:~880`), which sits strictly BELOW every dispatcher. It + * never crosses the privacy handle at all. `extractStructured` documents + * this intent outright: "Returns the payload exactly as the server sent + * it ... never a re-parse of the rendered string." + * + * That "strictly below every dispatcher" is why this file proves the property + * using `ToolDispatchService` rather than a full `Orchestrator` turn: the sink + * fires beneath the dispatcher, so the asymmetry is dispatcher-independent. The + * interning contract asserted here is the same one the chat path uses and is + * documented as parity in `toolDispatchPrivacySeam.test.ts`. + * + * There is also no way to close that asymmetry inside W5-2's scope. The whole privacy + * contract (`PrivacyTurnHandle`) is string-in/string-out: + * `internToolResultV4({rawResult: string}) -> {digestText: string}`. Feeding a + * structured payload through it returns a digest STRING — the structure the + * card exists to render is destroyed, leaving something strictly worse than + * the `ToolRow` that already shows that digest. Masking structure while + * preserving it needs a NEW method on the published `@omadia/plugin-api` + * surface plus a privacy-guard implementation and boot wiring. That is an + * issue, not a commit. + * + * MUTATION-CHECK DISCIPLINE (same as `toolDispatchPrivacySeam.test.ts`): every + * assertion inspects CONTENT that crosses a boundary. None asserts "a masking + * function was called" — a call-count assertion stays green over a masking + * function that returns its input unchanged, the exact false-green this repo + * has been burned by. The privacy handle here performs a REAL redaction. + */ + +import { after, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { + createServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { Server as McpSdkServer } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +// Imported from SOURCE, not from the `@omadia/orchestrator` barrel. The barrel +// resolves to `dist/`, so a mutation applied to `src/` would not be visible +// without a rebuild — and a mutation check that silently exercises a stale +// artifact reports GREEN over broken production code. Same convention as +// `toolDispatchPrivacySeam.test.ts`. Keeping every orchestrator import on the +// source path also guarantees ONE module instance, so the `turnContext` +// AsyncLocalStorage the sidecar reads is the one this file writes. +import { + McpManager, + mcpNativeHandler, + type McpServerConfig, + type McpSidecarPayload, + type McpStructuredOutputSidecar, +} from '../packages/harness-orchestrator/src/mcp/mcpClient.js'; +import { NativeToolRegistry } from '../packages/harness-orchestrator/src/nativeToolRegistry.js'; +import { ToolDispatchService } from '../packages/harness-orchestrator/src/toolDispatchService.js'; +import { turnContext } from '../packages/harness-orchestrator/src/turnContext.js'; +import type { PrivacyTurnHandle } from '../packages/harness-orchestrator/src/privacyHandle.js'; + +// ── fixtures ──────────────────────────────────────────────────────────────── + +const EMAIL = 'erika.mustermann@example.com'; +const IBAN = 'DE89370400440532013000'; +const PERSON = 'Erika Mustermann'; + +/** The tool's declared output shape — what a generic renderer would key off. */ +const OUTPUT_SCHEMA = { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string' }, + iban: { type: 'string' }, + }, +} as const; + +/** The server answers with the SAME PII in both channels: the text block (which + * the dispatcher interns) and `structuredContent` (which nothing interns). */ +const STRUCTURED_PAYLOAD = { + name: PERSON, + email: EMAIL, + iban: IBAN, +} as const; + +const TEXT_PAYLOAD = `{"name":"${PERSON}","email":"${EMAIL}","iban":"${IBAN}"}`; + +const TOOL = 'crm_lookup_customer'; + +/** + * A privacy handle that genuinely redacts, so a missing masking call shows up + * as surviving raw PII rather than as an unmet expectation. + */ +function redactingPrivacyHandle(): PrivacyTurnHandle { + return { + async internToolResultV4({ toolName, rawResult }) { + const redacted = rawResult + .replaceAll(EMAIL, '[masked:email]') + .replaceAll(IBAN, '[masked:iban]') + .replaceAll(PERSON, '[masked:person]'); + return { digestText: `«dataset:${toolName}» ${redacted}`, datasetId: `ds-${toolName}` }; + }, + async recordBypassedTool() { + /* no bypass configured in this file */ + }, + checkBypass() { + return undefined; + }, + async runV4Tool() { + throw new Error('not used on this path'); + }, + async subAgentResultV4() { + throw new Error('not used on this path'); + }, + async takeRenderedAnswerV4() { + return undefined; + }, + v4ToolSpecs() { + return []; + }, + async maskUserPrompt() { + return { outcome: 'disabled' }; + }, + async restorePromptPseudonyms(text) { + return text; + }, + snapshotPromptRestorer() { + return undefined; + }, + async finalize() { + return undefined; + }, + }; +} + +// ── a real MCP server over a real socket ──────────────────────────────────── + +interface FakeServerHandle { + readonly url: string; + close(): Promise; +} + +function buildMcpServerInstance(): McpSdkServer { + const mcp = new McpSdkServer( + { name: 'crm', version: '1.0.0' }, + { capabilities: { tools: {} } }, + ); + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: TOOL, + description: 'look up a customer record', + inputSchema: { type: 'object' as const, properties: {} }, + outputSchema: OUTPUT_SCHEMA, + }, + ], + })); + mcp.setRequestHandler(CallToolRequestSchema, async () => ({ + content: [{ type: 'text' as const, text: TEXT_PAYLOAD }], + structuredContent: STRUCTURED_PAYLOAD, + })); + return mcp; +} + +async function startFakeMcpServer(): Promise { + const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + const mcp = buildMcpServerInstance(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on('close', () => { + void transport.close().catch(() => {}); + void mcp.close().catch(() => {}); + }); + await mcp.connect(transport); + if (req.method === 'POST') { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8'); + await transport.handleRequest(req, res, raw.length > 0 ? JSON.parse(raw) : undefined); + return; + } + await transport.handleRequest(req, res); + }; + const http: HttpServer = createServer((req, res) => { + void handle(req, res).catch(() => { + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + }); + const sockets = new Set(); + http.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => http.listen(0, '127.0.0.1', () => resolve())); + const { port } = http.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/mcp`, + async close() { + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await new Promise((resolve) => http.close(() => resolve())); + }, + }; +} + +const fake = await startFakeMcpServer(); +const managers: McpManager[] = []; + +// Teardown runs regardless of assertion outcome — a server closed only after a +// passing assertion turns a red run into a HANG, which is how a sibling agent's +// mutation check failed to report in this wave. +after(async () => { + for (const m of managers) { + try { + await m.closeAll(); + } catch { + /* teardown must not mask a test failure */ + } + } + await fake.close(); +}); + +const CFG: McpServerConfig = { + id: '00000000-0000-4000-8000-000000000547', + name: 'Kunden-CRM', + transport: 'http', + endpoint: fake.url, +}; + +interface Harness { + readonly manager: McpManager; + readonly sidecars: McpSidecarPayload[]; + readonly service: ToolDispatchService; +} + +/** Wire the REAL production chain: `ToolDispatchService` -> `NativeToolRegistry` + * -> `mcpNativeHandler` -> `McpManager.callTool` -> `structuredSink`. */ +function harness(): Harness { + const sidecars: McpSidecarPayload[] = []; + const manager = new McpManager({ structuredSink: (p) => sidecars.push(p) }); + managers.push(manager); + const nativeTools = new NativeToolRegistry(); + nativeTools.register(TOOL, { + handler: mcpNativeHandler(manager, CFG, TOOL), + spec: { + name: TOOL, + description: 'look up a customer record', + input_schema: { type: 'object', properties: {} }, + }, + domain: 'mcp.kunden-crm', + }); + const service = new ToolDispatchService({ + nativeTools, + domainTools: [], + privacy: () => redactingPrivacyHandle(), + }); + return { manager, sidecars, service }; +} + +function structuredSidecars( + sidecars: readonly McpSidecarPayload[], +): McpStructuredOutputSidecar[] { + return sidecars.filter( + (p): p is McpStructuredOutputSidecar => p.kind === 'structured_output', + ); +} + +// ── the finding ───────────────────────────────────────────────────────────── + +describe('#547 structured-output sidecar vs. the privacy boundary (W5-2)', () => { + it('the TEXT result IS interned at the dispatch seam, so the MODEL sees a digest', async () => { + const h = harness(); + + const result = await h.service.dispatch(TOOL, {}); + + // This is the benchmark the brief calls "the same terms as text output". + // `result.content` is the dispatcher's return value — the string bound for + // the model. What the BROWSER ultimately renders is a separate question + // (see the header): it is on the trusted side and gets real values. + assert.equal( + result.content.includes(EMAIL), + false, + 'the email reached the caller in clear — the text seam did not mask', + ); + assert.equal(result.content.includes(IBAN), false, 'the IBAN reached the caller in clear'); + assert.equal( + result.content.includes(PERSON), + false, + 'the person name reached the caller in clear', + ); + // Masked rather than dropped. + assert.match(result.content, /\[masked:email\]/); + assert.match(result.content, /«dataset:crm_lookup_customer»/); + }); + + it('the STRUCTURED sidecar is NOT interned, so it still carries the raw values', async () => { + const h = harness(); + + const result = await h.service.dispatch(TOOL, {}); + + // Same dispatch, same privacy handle installed, same PII. + assert.equal(result.content.includes(EMAIL), false, 'precondition: the text WAS masked'); + + const structured = structuredSidecars(h.sidecars); + assert.equal(structured.length, 1, 'exactly one structured sidecar for one call'); + const payload = structured[0]!.structured as Record; + + // The load-bearing assertions: raw values, byte-identical to what the + // server sent. Not a leak in itself — the browser is trusted — but it + // pins that ANY future consumer of this payload must treat it as unmasked, + // and that forwarding it across the model boundary would undo the + // interning the text path just performed. + assert.equal(payload['email'], EMAIL); + assert.equal(payload['iban'], IBAN); + assert.equal(payload['name'], PERSON); + assert.deepEqual(payload, STRUCTURED_PAYLOAD); + }); + + it('the sidecar is emitted BENEATH the dispatcher, so no dispatcher can mask it', async () => { + // Proves the asymmetry is structural rather than a property of one dispatcher: + // the payload is already in the sink by the time `dispatch` returns, and + // the value in the sink is unaffected by the masking that produced + // `result.content`. + const h = harness(); + + const result = await h.service.dispatch(TOOL, {}); + + const structured = structuredSidecars(h.sidecars); + assert.equal(structured.length, 1); + assert.match(result.content, /\[masked:person\]/, 'the string path was masked'); + assert.equal( + (structured[0]!.structured as Record)['name'], + PERSON, + 'the sidecar payload was NOT masked by the same dispatch', + ); + }); + + it('carries the declared `outputSchema`, so a generic renderer is buildable once masking exists', async () => { + // Not an exposure assertion — it records that the ONLY blocker is masking. The + // renderer contract the brief specifies (render from `outputSchema`, never + // by tool name) is already satisfiable end-to-end over a real wire. + const h = harness(); + await h.manager.listTools(CFG); // discovery caches the schema + + await h.service.dispatch(TOOL, {}); + + const structured = structuredSidecars(h.sidecars); + assert.equal(structured.length, 1); + assert.deepEqual(structured[0]!.outputSchema, OUTPUT_SCHEMA); + }); + + it('attaches the ambient `turnId`, so correlation onto a done event is already possible', async () => { + const h = harness(); + + await turnContext.run( + { turnId: 't-547', turnDate: '2026-07-31', agentSlug: 'main', userId: 'u1' } as never, + () => h.service.dispatch(TOOL, {}), + ); + + const structured = structuredSidecars(h.sidecars); + assert.equal(structured.length, 1); + assert.equal(structured[0]!.turnId, 't-547'); + }); + + it('emits NO sidecar for a tool whose result has no structuredContent', async () => { + // The `ToolRow` fallback case the brief requires to survive untouched. + const sidecars: McpSidecarPayload[] = []; + const manager = new McpManager({ structuredSink: (p) => sidecars.push(p) }); + managers.push(manager); + const nativeTools = new NativeToolRegistry(); + nativeTools.register('plain_tool', { + handler: async () => 'just text', + spec: { + name: 'plain_tool', + description: 'no structured output', + input_schema: { type: 'object', properties: {} }, + }, + domain: 'test.plain', + }); + const service = new ToolDispatchService({ nativeTools, domainTools: [] }); + + const result = await service.dispatch('plain_tool', {}); + + assert.equal(result.content, 'just text'); + assert.deepEqual(structuredSidecars(sidecars), []); + }); +}); diff --git a/middleware/test/mcpTransportDeprecation.test.ts b/middleware/test/mcpTransportDeprecation.test.ts new file mode 100644 index 00000000..538c88cc --- /dev/null +++ b/middleware/test/mcpTransportDeprecation.test.ts @@ -0,0 +1,117 @@ +/** + * Issue #541 — the legacy HTTP+SSE transport is Deprecated as of MCP 2026-07-28 + * (minimum 12-month removal window). This suite pins the two halves of that: + * + * 1. The API surface *flags* it — `mcpNode()` derives `transportDeprecated` + * from `DEPRECATED_MCP_TRANSPORTS` so the web-ui can badge legacy rows + * without hard-coding the spec's list. + * 2. Nothing *breaks* because of it — an existing `sse` row still gets a real + * `SSEClientTransport`. This is the regression guard: the unit is a + * discouragement, not a removal, and no runtime behaviour may change. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + DEPRECATED_MCP_TRANSPORTS, + isDeprecatedMcpTransport, + McpManager, + type McpServerConfig, + type McpServerRow, + type McpTransportKind, +} from '@omadia/orchestrator'; + +import { mcpNode } from '../src/routes/agentBuilder.js'; + +function serverRow(transport: McpTransportKind, endpoint: string | null): McpServerRow { + return { + id: `00000000-0000-4000-8000-0000000000${transport === 'sse' ? 'a1' : 'a2'}`, + name: `srv-${transport}`, + transport, + endpoint, + headers: {}, + secretRef: null, + status: 'enabled', + lastDiscoveredAt: null, + discoveredTools: [], + createdAt: new Date(0), + updatedAt: new Date(0), + source: 'manual', + registryId: null, + license: null, + author: null, + sourceUrl: null, + privacyBypass: false, + kgIngest: false, + configSchema: [], + config: {}, + }; +} + +/** `makeTransport` is private; the transport choice is exactly what this + * regression guard is about, so reach it through a narrow cast rather than + * standing up a live MCP server. */ +function makeTransport(cfg: McpServerConfig): unknown { + const manager = new McpManager(); + return ( + manager as unknown as { makeTransport(c: McpServerConfig, token?: string | null): unknown } + ).makeTransport(cfg); +} + +function serverConfig(transport: McpTransportKind, endpoint: string): McpServerConfig { + return { id: 'cfg-1', name: `srv-${transport}`, transport, endpoint }; +} + +describe('DEPRECATED_MCP_TRANSPORTS (#541)', () => { + it('lists exactly the legacy HTTP+SSE transport', () => { + assert.deepEqual([...DEPRECATED_MCP_TRANSPORTS], ['sse']); + }); + + it('classifies sse as deprecated and stdio/http as current', () => { + assert.equal(isDeprecatedMcpTransport('sse'), true); + assert.equal(isDeprecatedMcpTransport('http'), false); + assert.equal(isDeprecatedMcpTransport('stdio'), false); + // Unknown values must not be treated as deprecated. + assert.equal(isDeprecatedMcpTransport('websocket'), false); + }); +}); + +describe('mcpNode transportDeprecated (#541)', () => { + it('is true for an existing sse row', () => { + const node = mcpNode(serverRow('sse', 'https://legacy.example/sse')); + assert.equal(node.transportDeprecated, true); + // Additive only: the transport itself is returned unchanged, so the row + // keeps working and no migration/CHECK change is implied. + assert.equal(node.transport, 'sse'); + assert.equal(node.endpoint, 'https://legacy.example/sse'); + }); + + it('is false for an http row', () => { + const node = mcpNode(serverRow('http', 'https://modern.example/mcp')); + assert.equal(node.transportDeprecated, false); + assert.equal(node.transport, 'http'); + }); + + it('is false for a stdio row', () => { + const node = mcpNode(serverRow('stdio', 'npx -y -- @acme/notes-mcp')); + assert.equal(node.transportDeprecated, false); + }); +}); + +describe('sse regression — deprecation must not change runtime behaviour (#541)', () => { + it('still builds an SSEClientTransport for an sse server', () => { + const transport = makeTransport(serverConfig('sse', 'https://legacy.example/sse')); + assert.ok( + transport instanceof SSEClientTransport, + 'an existing sse row must still connect over SSE — the removal window is open', + ); + }); + + it('still builds a StreamableHTTPClientTransport for an http server', () => { + const transport = makeTransport(serverConfig('http', 'https://modern.example/mcp')); + assert.ok(transport instanceof StreamableHTTPClientTransport); + }); +}); diff --git a/middleware/test/mcpWriteIdempotency.test.ts b/middleware/test/mcpWriteIdempotency.test.ts new file mode 100644 index 00000000..465595c2 --- /dev/null +++ b/middleware/test/mcpWriteIdempotency.test.ts @@ -0,0 +1,436 @@ +import { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { + createServer, + type IncomingHttpHeaders, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'node:http'; +import type { AddressInfo } from 'node:net'; + +// IMPORTANT: every symbol here comes from `packages/.../src`, never from the +// built `@omadia/orchestrator` entry point. The idempotency scope is carried by +// an `AsyncLocalStorage` instance that lives in a module — importing `McpManager` +// from `dist` while importing `ToolDispatchService` from `src` gives two separate +// module graphs, hence two separate ALS instances, and the scope silently never +// reaches the transport layer. That failure looks exactly like a broken feature. +import { LoopbackMcpServer } from '../packages/harness-orchestrator/src/loopbackMcpServer.js'; +import { + McpManager, + mcpNativeHandler, + type McpServerConfig, +} from '../packages/harness-orchestrator/src/mcp/mcpClient.js'; +import { NativeToolRegistry } from '../packages/harness-orchestrator/src/nativeToolRegistry.js'; +import { ToolDispatchService } from '../packages/harness-orchestrator/src/toolDispatchService.js'; +import { ToolIdempotencyStore } from '../packages/harness-orchestrator/src/toolIdempotency.js'; +import type { WriteCapability } from '../packages/plugin-api/src/writeCapabilities.js'; + +/** + * #542 prerequisite — duplicate-write protection across the MCP transport retry. + * + * `McpManager.callTool` retries ONCE on a transient transport failure. That is a + * deliberate, shipped mitigation for a flaky hosted proxy and it stays. But a + * transient failure is indistinguishable from "the server executed the write and + * the response was lost on the way back", so for a write-capable tool the retry + * can duplicate a mutation. + * + * THE MUTATION CHECK: the proxy below forwards the first `tools/call` UPSTREAM — + * so the server really executes and really records a side effect — and only THEN + * replaces the response with a transport error. `writes()` counts side effects + * observed by the server itself, not mock invocations. The control test proves + * the hazard is real (2 writes); the protected test proves the fix (1 write). + */ + +const BEARER = 'loopback-secret-token'; +const REMOTE_TOOL = 'create_invoice'; +const LOCAL_TOOL = 'odoo_create_invoice'; +const CREATE_INVOICE: readonly WriteCapability[] = [ + { dataClass: 'odoo.invoice', operation: 'create' }, +]; + +const HOP_BY_HOP = new Set([ + 'connection', + 'content-length', + 'host', + 'keep-alive', + 'transfer-encoding', + 'upgrade', +]); + +function forwardableHeaders(headers: IncomingHttpHeaders): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (HOP_BY_HOP.has(key.toLowerCase()) || value === undefined) continue; + out[key] = Array.isArray(value) ? value.join(', ') : value; + } + return out; +} + +function isSandboxListenError(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as { code?: string }).code === 'EPERM' + ); +} + +function serverConfig(url: string): McpServerConfig { + return { + id: '00000000-0000-4000-8000-0000000wr1te'.replace('wr1te', 'c0ded'), + name: 'loopback-write', + transport: 'http', + endpoint: url, + headers: { Authorization: `Bearer ${BEARER}` }, + }; +} + +interface LosingProxy { + readonly url: string; + readonly toolCallCount: () => number; + readonly stop: () => Promise; +} + +/** + * Proxy that loses the RESPONSE to the first `tools/call` after the upstream + * server already handled it. This is the dangerous shape the retry cannot + * distinguish: the write happened, the caller only saw a dropped connection. + * + * `targets` holds two upstreams because a `LoopbackMcpServer` accepts exactly one + * Streamable-HTTP session — the retry legitimately reconnects, so it must land on + * a second instance, modelling the hosted proxy failing over to a healthy node. + */ +async function startLosingProxy(targets: readonly string[]): Promise { + let toolCalls = 0; + let targetIdx = 0; + + const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk); + } + const body = Buffer.concat(chunks); + const text = body.toString('utf8'); + const isToolCall = req.method === 'POST' && text.includes('"tools/call"'); + if (isToolCall) toolCalls += 1; + const loseResponse = isToolCall && toolCalls === 1; + + const target = targets[targetIdx] ?? targets[0]!; + const upstream = await fetch(target, { + method: req.method ?? 'GET', + headers: forwardableHeaders(req.headers), + ...(body.length > 0 ? { body } : {}), + }); + // Drain upstream so the server completes the call (and its side effect). + const upstreamText = await upstream.text(); + + if (loseResponse) { + // The write DID happen upstream. Now drop the answer on the floor and hand + // back the transport error a flaky hosted proxy actually returns. + targetIdx = Math.min(targetIdx + 1, targets.length - 1); + const id = (() => { + try { + return (JSON.parse(text) as { id?: unknown }).id ?? null; + } catch { + return null; + } + })(); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id, + error: { code: -32000, message: 'Connection closed' }, + }), + ); + return; + } + + const responseHeaders: Record = {}; + upstream.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) responseHeaders[key] = value; + }); + res.writeHead(upstream.status, responseHeaders); + res.end(upstreamText); + }; + + const server: Server = createServer((req, res) => { + void handle(req, res).catch(() => { + if (!res.headersSent) res.writeHead(502); + res.end(); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${String(port)}/mcp`, + toolCallCount: () => toolCalls, + stop: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +describe('write-capable MCP tool — duplicate-write protection (#542 prerequisite)', () => { + const servers: LoopbackMcpServer[] = []; + let proxy: LosingProxy | undefined; + let manager: McpManager | undefined; + + afterEach(async () => { + await manager?.closeAll(); + await proxy?.stop(); + for (const s of servers.splice(0)) await s.stop(); + manager = undefined; + proxy = undefined; + }); + + /** + * A real `LoopbackMcpServer` whose tool records a side effect per execution. + * `writes` is shared across instances so a retry landing on the second server + * still increments the SAME counter — that is what makes it a true count of + * effects rather than a per-connection statistic. + */ + async function startWriteServer( + t: { skip: (reason: string) => void }, + writes: string[], + ): Promise { + const remote = new NativeToolRegistry(); + remote.register(REMOTE_TOOL, { + handler: async (input) => { + writes.push(JSON.stringify(input)); + return `invoice #${String(writes.length)} created`; + }, + spec: { + name: REMOTE_TOOL, + description: 'creates an invoice (side effect)', + input_schema: { type: 'object', properties: { amount: { type: 'number' } } }, + }, + domain: 'test.odoo', + }); + const server = new LoopbackMcpServer({ + dispatch: new ToolDispatchService({ + nativeTools: remote, + domainTools: [], + }), + bearer: BEARER, + tools: [ + { + name: REMOTE_TOOL, + description: 'creates an invoice (side effect)', + input_schema: { + type: 'object', + properties: { amount: { type: 'number' } }, + }, + }, + ], + }); + try { + const handle = await server.start(); + servers.push(server); + return handle.url; + } catch (error) { + if (isSandboxListenError(error)) { + t.skip('sandbox blocks loopback listeners on 127.0.0.1'); + return undefined; + } + throw error; + } + } + + /** Local dispatcher whose write tool forwards to the remote MCP server. */ + function localDispatcher( + mgr: McpManager, + cfg: McpServerConfig, + options: { readonly declareWrite: boolean; readonly store?: ToolIdempotencyStore }, + ): ToolDispatchService { + const nativeTools = new NativeToolRegistry(); + nativeTools.register(LOCAL_TOOL, { + handler: mcpNativeHandler(mgr, cfg, REMOTE_TOOL), + spec: { + name: LOCAL_TOOL, + description: 'creates an invoice', + input_schema: { type: 'object', properties: { amount: { type: 'number' } } }, + }, + domain: 'test.odoo', + ...(options.declareWrite ? { writeCapabilities: CREATE_INVOICE } : {}), + }); + return new ToolDispatchService({ + nativeTools, + domainTools: [], + ...(options.store !== undefined ? { idempotency: options.store } : {}), + }); + } + + it('CONTROL — without an idempotency key the lost response duplicates the write', async (t) => { + const writes: string[] = []; + const first = await startWriteServer(t, writes); + if (!first) return; + const second = await startWriteServer(t, writes); + if (!second) return; + proxy = await startLosingProxy([first, second]); + + manager = new McpManager(); + const dispatcher = localDispatcher(manager, serverConfig(proxy.url), { + declareWrite: true, + }); + + await dispatcher.dispatch(LOCAL_TOOL, { amount: 100 }); + + // This is the hazard, demonstrated rather than asserted in prose: the server + // executed the write, the response was lost, the retry executed it AGAIN. + assert.equal(proxy.toolCallCount(), 2, 'the shipped once-retry must still fire here'); + assert.equal( + writes.length, + 2, + 'baseline: the write really does happen twice when the response is lost', + ); + }); + + it('executes the write EXACTLY ONCE under an idempotency key, despite the lost response', async (t) => { + const writes: string[] = []; + const first = await startWriteServer(t, writes); + if (!first) return; + const second = await startWriteServer(t, writes); + if (!second) return; + proxy = await startLosingProxy([first, second]); + + manager = new McpManager(); + const dispatcher = localDispatcher(manager, serverConfig(proxy.url), { + declareWrite: true, + store: new ToolIdempotencyStore(), + }); + + const result = await dispatcher.dispatch( + LOCAL_TOOL, + { amount: 100 }, + { idempotencyKey: 'req-invoice-1' }, + ); + + // THE load-bearing assertion: one real side effect on the server. + assert.equal( + writes.length, + 1, + 'the write executed more than once — duplicate customer data is exactly what this prevents', + ); + assert.equal( + proxy.toolCallCount(), + 1, + 'a write-capable call under an exactly-once scope must make a single attempt', + ); + // The caller still learns it failed — at-most-once means the caller may have + // to ask again with the same key, not that failure is hidden. `McpManager` + // never throws, it returns the failure as `Error: …` TEXT, so this surfaces as + // content rather than an `isError` flag. + assert.match( + result.content, + /Error:/, + 'suppressing the retry must not silently report success', + ); + }); + + it('MUTATION CHECK: the retry SHARES the absolute MCP budget instead of doubling it', async (t) => { + // W4. The retry used to start with a FRESH `maxTotalTimeout`, so one + // `callTool` was worth up to 2 x the absolute ceiling of wall clock — 360s + // against a 240s outer dispatch deadline, i.e. the very inversion W3-A + // removed, re-created by a knob nobody counted in the invariant. + // + // Driven through the real knob rather than a stub: with the ceiling set below + // the retry's minimum-remaining floor, the budget is provably spent after + // attempt 1 no matter how fast the machine is, so no second attempt may + // start. The CONTROL test above proves this same proxy produces TWO attempts + // when the budget allows it, so a green result here is the budget doing the + // work — not the retry having quietly disappeared. + const previousCeiling = process.env['OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS']; + process.env['OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS'] = '900'; + try { + const writes: string[] = []; + const first = await startWriteServer(t, writes); + if (!first) return; + const second = await startWriteServer(t, writes); + if (!second) return; + proxy = await startLosingProxy([first, second]); + + manager = new McpManager(); + // No idempotency key: the `exactlyOnce` clamp is NOT what suppresses the + // retry here — this is a plain read-shaped dispatch, exactly the CONTROL + // configuration that produced two attempts. + const dispatcher = localDispatcher(manager, serverConfig(proxy.url), { + declareWrite: false, + }); + + const result = await dispatcher.dispatch(LOCAL_TOOL, { amount: 100 }); + + assert.equal( + proxy.toolCallCount(), + 1, + 'attempt 2 started with a fresh allowance — the absolute ceiling is not absolute', + ); + assert.match(result.content, /Error:/, 'the failure must still be reported'); + } finally { + if (previousCeiling === undefined) { + delete process.env['OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS']; + } else { + process.env['OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS'] = previousCeiling; + } + } + }); + + it('a READ tool keeps the once-retry mitigation intact', async (t) => { + const writes: string[] = []; + const first = await startWriteServer(t, writes); + if (!first) return; + const second = await startWriteServer(t, writes); + if (!second) return; + proxy = await startLosingProxy([first, second]); + + manager = new McpManager(); + // No `writeCapabilities` ⇒ read-only, so the flaky-proxy mitigation applies + // even with a key present. Removing the retry outright would regress this. + const dispatcher = localDispatcher(manager, serverConfig(proxy.url), { + declareWrite: false, + store: new ToolIdempotencyStore(), + }); + + const result = await dispatcher.dispatch( + LOCAL_TOOL, + { amount: 100 }, + { idempotencyKey: 'req-read-1' }, + ); + + assert.equal(proxy.toolCallCount(), 2, 'the read must still be retried once'); + assert.equal(result.isError, undefined, 'and the retry must succeed'); + assert.match(result.content, /invoice #2 created/); + }); + + it('a duplicate dispatch under the SAME key does not reach the server again', async (t) => { + const writes: string[] = []; + const url = await startWriteServer(t, writes); + if (!url) return; + + manager = new McpManager(); + const dispatcher = localDispatcher(manager, serverConfig(url), { + declareWrite: true, + store: new ToolIdempotencyStore(), + }); + + const a = await dispatcher.dispatch( + LOCAL_TOOL, + { amount: 100 }, + { idempotencyKey: 'req-invoice-2' }, + ); + const b = await dispatcher.dispatch( + LOCAL_TOOL, + { amount: 100 }, + { idempotencyKey: 'req-invoice-2' }, + ); + + assert.equal(writes.length, 1, 'the caller retry must not produce a second invoice'); + assert.equal(a.content, b.content, 'the retry must receive the original result'); + assert.match(a.content, /invoice #1 created/); + }); +}); diff --git a/middleware/test/orchestrator/chatPathToolErrorText.test.ts b/middleware/test/orchestrator/chatPathToolErrorText.test.ts new file mode 100644 index 00000000..329d69d1 --- /dev/null +++ b/middleware/test/orchestrator/chatPathToolErrorText.test.ts @@ -0,0 +1,221 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { LlmProvider, LlmResponse } from '@omadia/llm-provider'; +import type { PrivacyGuardService } from '@omadia/plugin-api'; +import { NativeToolRegistry, Orchestrator } from '@omadia/orchestrator'; + +/** + * W4 guard — the CHAT path's handling of a tool exception must stay unchanged. + * + * `ToolDispatchService` (the loopback / public-MCP entry point) now masks a + * throwing handler's message before returning it. That is a DELIBERATE + * divergence from the chat path, whose reader is the operator: an operator + * debugging their own integration needs the driver's real message, and silently + * digesting it would be a behaviour change nobody asked for. + * + * This file is the fence around that divergence. It drives the REAL + * `Orchestrator` with a real privacy handle installed on `turnContext` — the + * exact configuration in which the chat path DOES mask successful results — and + * asserts the exception text still arrives verbatim. If someone later "unifies" + * the two paths by moving `maskErrorText` into `dispatchToolDeadlined`, this + * fails. + * + * Mutation-check discipline: the assertion reads the tool_result CONTENT the + * orchestrator hands back to the model, and the handle below performs a REAL + * redaction, so a masking call that did happen would be visible as the absence + * of the raw string. + */ + +const EMAIL = 'erika.mustermann@example.com'; +const PII_ERROR = `Fault: Invalid field 'x' on record {"email":"${EMAIL}"}`; +const PII_RESULT = `{"email":"${EMAIL}"}`; + +const usage = { + inputTokens: 10, + outputTokens: 2, + cacheReadTokens: 0, + cacheWriteTokens: 0, +} as const; + +function toolCallResponse(name: string): LlmResponse { + return { + content: [{ type: 'tool_call', id: 'use-1', name, input: {} }], + finishReason: 'tool_calls', + providerFinishReason: 'tool_use', + model: 'test', + usage, + } as unknown as LlmResponse; +} + +function textResponse(text: string): LlmResponse { + return { + content: [{ type: 'text', text }], + finishReason: 'stop', + providerFinishReason: 'end_turn', + model: 'test', + usage, + } as unknown as LlmResponse; +} + +/** Records every message list handed to the provider, so the tool_result the + * model would have seen can be inspected directly. */ +function recordingProvider(responses: readonly LlmResponse[]): { + provider: LlmProvider; + seen: unknown[][]; +} { + const seen: unknown[][] = []; + let idx = 0; + const provider = { + id: 'anthropic', + capabilities: { + tools: true, + vision: true, + streaming: true, + promptCaching: true, + forcedToolChoice: true, + parallelToolCalls: true, + }, + complete: (request: { messages?: unknown[] }) => { + seen.push(request.messages ?? []); + const response = responses[idx]; + idx += 1; + if (!response) throw new Error('recordingProvider: no scripted response left'); + return Promise.resolve(response); + }, + stream: () => { + throw new Error('not used'); + }, + classifyError: () => ({ retryable: false, kind: 'other' as const }), + }; + return { provider: provider as unknown as LlmProvider, seen }; +} + +/** + * The REAL `privacyGuard` seam the orchestrator builds its per-turn handle from + * — not a handle injected into `turnContext`, which `runTurn` would overwrite + * with its own. Redacts for real, so "masking ran" is observable as a missing + * raw string rather than as a call count. + */ +function maskingPrivacyService(): PrivacyGuardService { + return { + async internToolResultV4(request: { toolName: string; rawResult: string }) { + return { + digestText: `«dataset:${request.toolName}» ${request.rawResult.replaceAll(EMAIL, '[masked:email]')}`, + datasetId: `ds-${request.toolName}`, + }; + }, + async recordBypassedTool() {}, + async runV4Tool() { + return { resultText: '' }; + }, + async subAgentResultV4() { + return { resultText: '' }; + }, + async takeRenderedAnswerV4() { + return undefined; + }, + v4ToolSpecs() { + return []; + }, + async finalizeTurn() { + return undefined; + }, + } as unknown as PrivacyGuardService; +} + +function registryWith(name: string, behaviour: () => Promise): NativeToolRegistry { + const registry = new NativeToolRegistry(); + registry.register(name, { + handler: behaviour, + spec: { + name, + description: 'test tool', + input_schema: { type: 'object' as const, properties: {}, required: [] }, + } as never, + domain: 'test.pii', + }); + return registry; +} + +function toolResultTexts(messages: readonly unknown[][]): string[] { + const out: string[] = []; + for (const list of messages) { + for (const message of list) { + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) continue; + for (const block of content) { + const b = block as { type?: string; content?: unknown }; + if (b.type === 'tool_result' && typeof b.content === 'string') out.push(b.content); + } + } + } + return out; +} + +function orchestratorWith( + provider: LlmProvider, + registry: NativeToolRegistry, +): Orchestrator { + return new Orchestrator({ + provider, + model: 'test', + maxTokens: 1024, + maxToolIterations: 3, + domainTools: [], + nativeToolRegistry: registry, + // The production seam: `runTurn` mints its own per-turn handle from this + // and threads it through `turnContext` itself. + privacyGuard: () => maskingPrivacyService(), + }); +} + +describe('chat path — tool exception text (W4 fence)', () => { + it('still hands the RAW exception message to the model, unmasked', async () => { + const { provider, seen } = recordingProvider([ + toolCallResponse('odoo_search_partner'), + textResponse('done'), + ]); + const orchestrator = orchestratorWith( + provider, + registryWith('odoo_search_partner', () => { + throw new Error(PII_ERROR); + }), + ); + + await orchestrator.runTurn({ userMessage: 'go' }); + + const results = toolResultTexts(seen); + assert.equal(results.length, 1, 'exactly one tool_result should have reached the model'); + assert.equal( + results[0]?.includes(EMAIL), + true, + 'the chat path must NOT have started masking exception text — that divergence is deliberate', + ); + assert.equal( + results[0]?.includes('[masked:email]'), + false, + 'a digest here means the dispatcher-only error masking bled into the chat path', + ); + }); + + it('and STILL masks a successful result in the same configuration', async () => { + // The control. Without this, the test above would also pass if the privacy + // handle were simply never consulted — which would make it vacuous. + const { provider, seen } = recordingProvider([ + toolCallResponse('odoo_read_partner'), + textResponse('done'), + ]); + const orchestrator = orchestratorWith( + provider, + registryWith('odoo_read_partner', () => Promise.resolve(PII_RESULT)), + ); + + await orchestrator.runTurn({ userMessage: 'go' }); + + const results = toolResultTexts(seen); + assert.equal(results.length, 1); + assert.equal(results[0]?.includes(EMAIL), false, 'the chat path must still mask RESULTS'); + assert.match(results[0] ?? '', /\[masked:email\]/); + }); +}); diff --git a/middleware/test/orchestrator/deterministicToolOrder.test.ts b/middleware/test/orchestrator/deterministicToolOrder.test.ts new file mode 100644 index 00000000..8a1480ee --- /dev/null +++ b/middleware/test/orchestrator/deterministicToolOrder.test.ts @@ -0,0 +1,222 @@ +/** + * W0-3 — the tool block handed to the provider must serialize identically for + * a given tool SET, regardless of the order the tools were registered in. + * + * `buildToolsList()` stamps `cache_control: { type: 'ephemeral' }` on the last + * tool spec, which makes the whole block one Anthropic prompt-cache chunk. The + * cache keys on a byte-exact prefix, so any reordering is a silent, total cache + * miss for the tool block and everything after it. The dynamic segments are + * iterated out of Maps — plugin load order for the native registry, `created_at` + * row order for domain tools — so before the name sort, two Fly machines could + * legitimately produce different byte streams for identical configuration. + */ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import type { + LlmProvider, + LlmRequest, + LlmResponse, + LlmStreamEvent, +} from '@omadia/llm-provider'; +import type { DomainTool } from '../../packages/harness-orchestrator/src/tools/domainQueryTool.js'; +import { NativeToolRegistry, Orchestrator } from '@omadia/orchestrator'; + +const providerCapabilities = { + tools: true, + vision: true, + streaming: true, + promptCaching: true, + forcedToolChoice: true, + parallelToolCalls: true, +} as const; + +const finalTextStream: LlmStreamEvent[] = [ + { type: 'text_delta', text: 'done' }, + { + type: 'final', + response: { + content: [{ type: 'text', text: 'done' }], + finishReason: 'stop', + providerFinishReason: 'end_turn', + model: 'test', + usage: { + inputTokens: 100, + outputTokens: 1, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }, + }, +]; + +function recordingProvider(seenRequests: LlmRequest[]): LlmProvider { + return { + id: 'anthropic', + capabilities: providerCapabilities, + complete: async (): Promise => { + throw new Error('complete() not scripted'); + }, + stream: (req: LlmRequest): AsyncIterable => { + seenRequests.push(req); + return { + async *[Symbol.asyncIterator]() { + for (const ev of finalTextStream) yield ev; + }, + }; + }, + classifyError: () => ({ retryable: false, kind: 'other' as const }), + } as unknown as LlmProvider; +} + +const minimalSpec = (name: string): Record => ({ + name, + description: `${name} for testing`, + input_schema: { type: 'object' as const, properties: {}, required: [] }, +}); + +function domainTool(name: string): DomainTool { + return { + name, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + spec: minimalSpec(name) as any, + domain: `domain.${name}`, + async handle() { + return `${name}-output`; + }, + } as DomainTool; +} + +const NATIVE_NAMES = ['n_yankee', 'n_alpha', 'n_mike', 'n_bravo'] as const; +const DOMAIN_NAMES = ['d_zulu', 'd_charlie', 'd_papa', 'd_delta'] as const; + +/** + * Builds an orchestrator with the given registration orders, runs one turn, + * and returns the request the provider actually saw. + * + * `buildToolsList()` output is not observable directly: `llmProviderSeam` + * translates the Anthropic-shaped specs into the neutral `LlmRequest.tools` + * (`input_schema` → `inputSchema`, `type` → `serverType`) and collapses the + * per-tool `cache_control` into the request-level `cacheHints.tools` flag. The + * Anthropic adapter then re-stamps `cache_control` on the LAST tool — which + * `test/llmProviderAnthropicAdapter.test.ts` already covers. `LlmRequest.tools` + * is therefore the ordered payload the wire tool block is built from, and the + * right place to pin ordering. + */ +async function buildRequest( + nativeOrder: readonly string[], + domainOrder: readonly string[], +): Promise { + const registry = new NativeToolRegistry(); + for (const name of nativeOrder) { + registry.register(name, { + handler: async () => `${name}-output`, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + spec: minimalSpec(name) as any, + agentId: `plugin-${name}`, + }); + } + + const seenRequests: LlmRequest[] = []; + const orchestrator = new Orchestrator({ + provider: recordingProvider(seenRequests), + model: 'test', + maxTokens: 1024, + maxToolIterations: 5, + domainTools: domainOrder.map(domainTool), + nativeToolRegistry: registry, + }); + + for await (const _ev of orchestrator.chatStream({ userMessage: 'go' })) { + // drain + } + + const request = seenRequests[0]; + assert.ok(request, 'provider received no request'); + return request; +} + +const toolNames = (request: LlmRequest): string[] => + (request.tools ?? []).map((tool) => tool.name); + +/** A deterministic shuffle, so a failure is reproducible rather than flaky. */ +function rotate(items: readonly T[], by: number): T[] { + const offset = ((by % items.length) + items.length) % items.length; + return [...items.slice(offset), ...items.slice(0, offset)]; +} + +describe('W0-3 — deterministic tool ordering', () => { + it('produces an identical name sequence for shuffled registration orders', async () => { + const runA = await buildRequest(NATIVE_NAMES, DOMAIN_NAMES); + const runB = await buildRequest( + rotate(NATIVE_NAMES, 3), + rotate(DOMAIN_NAMES, 2), + ); + const runC = await buildRequest( + [...NATIVE_NAMES].reverse(), + [...DOMAIN_NAMES].reverse(), + ); + + assert.deepEqual(toolNames(runA), toolNames(runB)); + assert.deepEqual(toolNames(runA), toolNames(runC)); + + // The dynamic segments are name-sorted; natives precede domain tools, and + // the deliberate fixed-literal prefix (memory, …) keeps its own order. + const names = toolNames(runA); + assert.deepEqual( + names.filter((n) => n.startsWith('n_')), + ['n_alpha', 'n_bravo', 'n_mike', 'n_yankee'], + ); + assert.deepEqual( + names.filter((n) => n.startsWith('d_')), + ['d_charlie', 'd_delta', 'd_papa', 'd_zulu'], + ); + assert.ok( + names.indexOf('n_yankee') < names.indexOf('d_charlie'), + 'native segment must stay ahead of the domain segment', + ); + }); + + it('keeps the fixed-literal prefix ahead of the sorted dynamic segments', async () => { + const names = toolNames(await buildRequest(NATIVE_NAMES, DOMAIN_NAMES)); + + // `memory` comes from the deliberate fixed prefix and sorts *after* every + // `d_*`/`n_*` name alphabetically — so finding it first proves the prefix + // was not swept into the sort. + assert.equal(names[0], 'memory'); + }); + + it('marks the tool block cacheable with a deterministic last element', async () => { + const runA = await buildRequest(NATIVE_NAMES, DOMAIN_NAMES); + const runB = await buildRequest( + rotate(NATIVE_NAMES, 2), + rotate(DOMAIN_NAMES, 1), + ); + + // `buildToolsList()` stamps `cache_control` on its last spec; the seam + // collapses that to `cacheHints.tools`, and the Anthropic adapter re-stamps + // the last tool (covered by test/llmProviderAnthropicAdapter.test.ts). + assert.equal(runA.cacheHints?.tools, true); + + // Which tool receives the stamp must not depend on registration order — + // that is exactly what used to drift between machines. + const lastA = toolNames(runA).at(-1); + assert.equal(lastA, toolNames(runB).at(-1)); + assert.equal(lastA, 'd_zulu'); + }); + + it('golden-snapshots byte-identically across two orchestrator rebuilds', async () => { + const first = await buildRequest(NATIVE_NAMES, DOMAIN_NAMES); + const second = await buildRequest( + rotate(NATIVE_NAMES, 1), + rotate(DOMAIN_NAMES, 3), + ); + + // The whole point of the sort: the serialized tool block — the exact bytes + // the prompt cache keys on — must match for the same tool set. + assert.equal( + JSON.stringify(first.tools), + JSON.stringify(second.tools), + ); + }); +}); diff --git a/middleware/test/orchestrator/mcpInputReplayPrivacy.test.ts b/middleware/test/orchestrator/mcpInputReplayPrivacy.test.ts new file mode 100644 index 00000000..be482933 --- /dev/null +++ b/middleware/test/orchestrator/mcpInputReplayPrivacy.test.ts @@ -0,0 +1,497 @@ +/** + * Issue #544 / W2-1 — Privacy Shield v4 on the MCP input-replay note. + * + * The MCP input-replay path re-calls a parked tool in a LATER turn and folds + * the result into the note that `withMcpInputNote` puts on the model's wire. + * That result was NOT interned: the replayer calls `McpManager.callTool` + * directly rather than through `dispatchTool`, so a personnel row coming back + * from an HR/accounting MCP server reached the LLM provider in cleartext. + * + * Coverage: + * 1. the replayed MCP result is interned before the note crosses the + * server ↔ LLM-provider boundary; + * 2. an operator-flagged MCP privacy bypass still passes the replay result + * through raw on that boundary (exempt stays exempt, still functional); + * 3. with no privacy handle the note keeps byte-identical legacy behaviour. + * + * WHY THE CARD IS PRE-SEEDED INTO THE STORE rather than parked by a real first + * turn: with a privacy handle installed, `dispatchTool` interns EVERY + * non-allowlisted tool result — including the `[mcp_input_required:]` + * sentinel — and `parseMcpInputSentinel` is deliberately anchored at the start + * of the string, so the card never materialises at all. That is a SEPARATE, + * pre-existing defect (Privacy Shield v4 vs. MRTR cards; `privacyInternPolicy.ts` + * exempts by tool NAME only and no MCP tool is ever on that list), reported + * alongside this fix and deliberately NOT papered over here. Pre-seeding the + * store isolates the replay half — the code this file exists to pin — from it. + * The full two-turn parking flow is covered by `mcpInputRequired.test.ts`. + * + * Imported from SOURCE, not from the `@omadia/orchestrator` barrel. The barrel + * resolves to `dist/`, so a mutation in `src/` would otherwise be invisible + * without a rebuild and a mutation check could report GREEN over stale code. + */ + +import { after, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { + createServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { Server as McpSdkServer } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import type { + LlmProvider, + LlmRequest, + LlmResponse, + LlmStreamEvent, +} from '@omadia/llm-provider'; +import type { ChatStreamEvent, PendingMcpInputCard } from '@omadia/channel-sdk'; +import type { PrivacyGuardService } from '@omadia/plugin-api'; +import { + InMemoryPendingMcpInputStore, + type McpInputReplayer, + type PendingMcpInput, + formatMcpInputReply, + resetSharedMcpInputWiring, +} from '../../packages/harness-orchestrator/src/mcp/pendingMcpInput.js'; +import { + McpManager, + REPLAY_ARG_KEY, + mcpNativeHandler, + type McpServerConfig, +} from '../../packages/harness-orchestrator/src/mcp/mcpClient.js'; +import { setMcpPrivacyBypassServers } from '../../packages/harness-orchestrator/src/mcpPrivacyBypass.js'; +import { NativeToolRegistry } from '../../packages/harness-orchestrator/src/nativeToolRegistry.js'; +import { Orchestrator } from '../../packages/harness-orchestrator/src/orchestrator.js'; + +const PERSON = 'Erika Mustermann'; +const EMAIL = 'erika.mustermann@example.com'; +const IBAN = 'DE89370400440532013000'; +const RAW_ROW = + `Personalakte: ${PERSON} | Email: ${EMAIL} | IBAN: ${IBAN} | Status: aktiv`; +const DIGEST_MARKER = '«dataset:lookup_employee_record»'; +const MCP_TOOL_NAME = 'mcp__HR_Payroll__lookup_employee_record'; + +const providerCapabilities = { + tools: true, + vision: true, + streaming: true, + promptCaching: true, + forcedToolChoice: true, + parallelToolCalls: true, +} as const; + +const serverArgs: Array> = []; +const managers = new Set(); + +function clearSharedState(): void { + setMcpPrivacyBypassServers([]); + resetSharedMcpInputWiring(); +} + +function redactingPrivacyService(): PrivacyGuardService { + return { + async internToolResultV4(request: { toolName: string; rawResult: string }) { + const redacted = request.rawResult + .replaceAll(PERSON, '[masked:person]') + .replaceAll(EMAIL, '[masked:email]') + .replaceAll(IBAN, '[masked:iban]'); + return { + digestText: `${DIGEST_MARKER} ${redacted}`, + datasetId: `ds-${request.toolName}`, + }; + }, + async recordBypassedTool() {}, + async runV4Tool() { + return { resultText: '' }; + }, + async subAgentResultV4() { + return { resultText: '' }; + }, + async takeRenderedAnswerV4() { + return undefined; + }, + v4ToolSpecs() { + return []; + }, + async finalizeTurn() { + return undefined; + }, + } as unknown as PrivacyGuardService; +} + +function buildMcpServerInstance(): McpSdkServer { + const mcp = new McpSdkServer( + { name: 'payroll', version: '0.0.0' }, + { capabilities: { tools: {} } }, + ); + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [{ name: 'lookup_employee_record', inputSchema: { type: 'object' as const } }], + })); + mcp.setRequestHandler(CallToolRequestSchema, async (request) => { + const args = (request.params.arguments ?? {}) as Record; + serverArgs.push(args); + const answers = args[REPLAY_ARG_KEY]; + if (answers !== undefined && answers !== null && typeof answers === 'object') { + return { + content: [{ type: 'text' as const, text: RAW_ROW }], + }; + } + return { + content: [{ type: 'text' as const, text: 'Bitte Personalnummer und PIN angeben.' }], + resultType: 'input_required', + inputRequests: [ + { name: 'employeeId', label: 'Personalnummer' }, + { name: 'pin', label: 'PIN', secret: true }, + ], + message: 'Bitte Personalnummer und PIN angeben.', + } as never; + }); + return mcp; +} + +async function startFakeMcpServer(): Promise<{ url: string; close(): Promise }> { + const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + const mcp = buildMcpServerInstance(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on('close', () => { + void transport.close().catch(() => {}); + void mcp.close().catch(() => {}); + }); + await mcp.connect(transport); + if (req.method === 'POST') { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8'); + await transport.handleRequest(req, res, raw.length > 0 ? JSON.parse(raw) : undefined); + return; + } + await transport.handleRequest(req, res); + }; + const http: HttpServer = createServer((req, res) => { + void handle(req, res).catch(() => { + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + }); + const sockets = new Set(); + http.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => http.listen(0, '127.0.0.1', () => resolve())); + const { port } = http.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/mcp`, + async close() { + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await new Promise((resolve) => http.close(() => resolve())); + }, + }; +} + +const fake = await startFakeMcpServer(); + +const CFG: McpServerConfig = { + id: '00000000-0000-4000-8000-000000000945', + name: 'HR Payroll', + transport: 'http', + endpoint: fake.url, +}; + +// Teardown runs regardless of assertion outcome, and every step is +// individually guarded: a server closed only after a passing assertion turns a +// RED run into a HANG, which is how a sibling agent's mutation check in this +// wave failed to report at all. Swallowing here is correct — teardown must +// never mask the failure that is already being reported. +after(async () => { + try { + clearSharedState(); + } catch { + /* teardown must not mask a test failure */ + } + for (const manager of managers) { + try { + await manager.closeAll(); + } catch { + /* teardown must not mask a test failure */ + } + } + try { + await fake.close(); + } catch { + /* teardown must not mask a test failure */ + } +}); + +function toolCallStream( + calls: Array<{ id: string; name: string; input: unknown }>, +): LlmStreamEvent[] { + return [ + { + type: 'final', + response: { + content: calls.map((c) => ({ + type: 'tool_call', + id: c.id, + name: c.name, + input: c.input, + })), + finishReason: 'tool_calls', + providerFinishReason: 'tool_use', + model: 'test', + usage: { inputTokens: 50, outputTokens: 4, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + } as LlmStreamEvent, + ]; +} + +function textStream(text: string): LlmStreamEvent[] { + return [ + { type: 'text_delta', text }, + { + type: 'final', + response: { + content: [{ type: 'text', text }], + finishReason: 'stop', + providerFinishReason: 'end_turn', + model: 'test', + usage: { inputTokens: 100, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + } as LlmStreamEvent, + ]; +} + +function fakeStreamProvider( + streams: LlmStreamEvent[][], + seenRequests: LlmRequest[], +): LlmProvider { + let idx = 0; + return { + id: 'anthropic', + capabilities: providerCapabilities, + complete: async (req: LlmRequest): Promise => { + seenRequests.push(req); + const events = streams[idx]; + idx += 1; + if (!events) throw new Error(`no scripted stream for provider call ${String(idx)}`); + const final = events.at(-1) as { response: LlmResponse }; + return final.response; + }, + stream: (req: LlmRequest): AsyncIterable => { + seenRequests.push(req); + const events = streams[idx]; + idx += 1; + if (!events) throw new Error(`no scripted stream for provider call ${String(idx)}`); + return { + async *[Symbol.asyncIterator]() { + for (const ev of events) yield ev; + }, + }; + }, + classifyError: () => ({ retryable: false, kind: 'other' as const }), + } as unknown as LlmProvider; +} + +interface Harness { + readonly orchestrator: Orchestrator; + readonly seenRequests: LlmRequest[]; + readonly store: InMemoryPendingMcpInputStore; +} + +function harness( + streams: LlmStreamEvent[][], + options?: { readonly privacyGuard?: () => PrivacyGuardService | undefined }, +): Harness { + const store = new InMemoryPendingMcpInputStore(); + const manager = new McpManager({ pendingInput: store }); + managers.add(manager); + const registry = new NativeToolRegistry(); + registry.register(MCP_TOOL_NAME, { + handler: mcpNativeHandler(manager, CFG, 'lookup_employee_record'), + spec: { + name: MCP_TOOL_NAME, + description: 'Look up an employee record.', + input_schema: { type: 'object' as const, properties: {}, required: [] }, + } as never, + agentId: 'mcp-test', + }); + const seenRequests: LlmRequest[] = []; + const replayer: McpInputReplayer = { + replay: async (record: PendingMcpInput, inputResponses: Record) => + manager.callTool(CFG, record.toolName, { + ...record.originalArgs, + [REPLAY_ARG_KEY]: inputResponses, + }), + }; + const orchestrator = new Orchestrator({ + provider: fakeStreamProvider(streams, seenRequests), + model: 'test', + maxTokens: 1024, + maxToolIterations: 5, + domainTools: [], + nativeToolRegistry: registry, + pendingMcpInput: store, + mcpInputReplay: replayer, + ...(options?.privacyGuard ? { privacyGuard: options.privacyGuard } : {}), + }); + return { orchestrator, seenRequests, store }; +} + +async function runStream( + orchestrator: Orchestrator, + userMessage: string, + sessionScope: string, + userId: string, +): Promise { + const events: ChatStreamEvent[] = []; + for await (const ev of orchestrator.chatStream({ userMessage, sessionScope, userId })) { + events.push(ev); + } + return events; +} + +function doneEvent(events: ChatStreamEvent[]): { + answer: string; + pendingMcpInput?: PendingMcpInputCard; +} { + const done = events.find((event) => event.type === 'done'); + assert.ok(done, 'no done event'); + return done as never; +} + +function wireUserText(requests: readonly LlmRequest[]): string { + const parts: string[] = []; + for (const req of requests) { + for (const message of (req.messages ?? []) as Array<{ role: string; content: unknown }>) { + if (message.role !== 'user') continue; + if (typeof message.content === 'string') { + parts.push(message.content); + continue; + } + if (!Array.isArray(message.content)) continue; + for (const block of message.content as Array<{ text?: string }>) { + if (typeof block.text === 'string') parts.push(block.text); + } + } + } + return parts.join('\n'); +} + +const SESSION = 'sess-1'; +const USER = 'u1'; + +/** + * Park a record and bind it to `(USER, SESSION)` exactly as a real first turn + * would: `put` stores it ownerless, `claim` binds the owner. `take` during the + * replay turn then needs the full `{userId, sessionId, correlationId}` triple, + * so the #445 ownership defence is exercised rather than bypassed. + */ +function seedParkedCard(h: Harness, correlationId: string): void { + const record: PendingMcpInput = { + correlationId, + serverId: CFG.id, + serverName: CFG.name, + toolName: 'lookup_employee_record', + originalArgs: { caseId: 'HR-7' }, + inputRequests: [ + { name: 'employeeId', required: true }, + { name: 'pin', secret: true, required: true }, + ], + replayDepth: 0, + }; + assert.equal(h.store.put(record), 'stored'); + assert.ok( + h.store.claim(correlationId, { userId: USER, sessionId: SESSION }), + 'the seeded record must claim, or the replay turn cannot take it', + ); +} + +/** + * Drive the replay turn and return the user-role text the LLM provider saw. + * This is the wire the fix is about — the browser is on the trusted side and + * is deliberately not asserted on here. + */ +async function replayWire( + h: Harness, + correlationId: string, + inputResponses: Record, +): Promise { + await runStream( + h.orchestrator, + formatMcpInputReply({ correlationId, inputResponses }), + SESSION, + USER, + ); + return wireUserText(h.seenRequests); +} + +describe('MCP input replay privacy boundary (#544 / W2-1)', () => { + it('MUTATION CHECK: the replay note interns the MCP result before it reaches the LLM wire', async () => { + clearSharedState(); + serverArgs.length = 0; + const h = harness([textStream('fertig')], { + privacyGuard: () => redactingPrivacyService(), + }); + seedParkedCard(h, 'corr-intern'); + + const wire = await replayWire(h, 'corr-intern', { employeeId: 'E-42', pin: '4321' }); + + // The server DID return the row — otherwise "absent from the wire" would + // pass vacuously over a replay that never happened. + assert.ok( + serverArgs.some((a) => a[REPLAY_ARG_KEY] !== undefined), + 'the replay never reached the MCP server', + ); + assert.equal(wire.includes(PERSON), false, `person name crossed the wire: ${wire}`); + assert.equal(wire.includes(EMAIL), false, `email crossed the wire: ${wire}`); + assert.equal(wire.includes(IBAN), false, `IBAN crossed the wire: ${wire}`); + assert.ok(wire.includes(DIGEST_MARKER), `digest marker missing from the wire: ${wire}`); + assert.ok(wire.includes('[masked:person]'), `masked payload missing from the wire: ${wire}`); + }); + + it('MUTATION CHECK: an MCP privacy-bypass server keeps the replay result raw on the wire', async () => { + clearSharedState(); + serverArgs.length = 0; + setMcpPrivacyBypassServers([CFG.id]); + try { + const h = harness([textStream('fertig')], { + privacyGuard: () => redactingPrivacyService(), + }); + seedParkedCard(h, 'corr-bypass'); + + const wire = await replayWire(h, 'corr-bypass', { employeeId: 'E-77', pin: '9999' }); + + assert.ok(wire.includes(PERSON), `person name missing from bypassed wire text: ${wire}`); + assert.ok(wire.includes(EMAIL), `email missing from bypassed wire text: ${wire}`); + assert.ok(wire.includes(IBAN), `IBAN missing from bypassed wire text: ${wire}`); + assert.equal(wire.includes(DIGEST_MARKER), false, `digest should not replace bypassed raw text: ${wire}`); + } finally { + setMcpPrivacyBypassServers([]); + } + }); + + it('MUTATION CHECK: without a privacy handle the replay note stays legacy-raw byte-for-byte', async () => { + clearSharedState(); + serverArgs.length = 0; + const h = harness([textStream('fertig')]); + seedParkedCard(h, 'corr-legacy'); + + const wire = await replayWire(h, 'corr-legacy', { employeeId: 'E-99', pin: '1111' }); + + assert.ok(wire.includes(RAW_ROW), `legacy raw replay result missing from the wire: ${wire}`); + assert.equal(wire.includes(DIGEST_MARKER), false, `digest should not appear without privacy guard: ${wire}`); + }); +}); diff --git a/middleware/test/orchestrator/mcpInputRequired.test.ts b/middleware/test/orchestrator/mcpInputRequired.test.ts new file mode 100644 index 00000000..3ed835b0 --- /dev/null +++ b/middleware/test/orchestrator/mcpInputRequired.test.ts @@ -0,0 +1,772 @@ +/** + * Issue #544 (W2-1) — the orchestrator half of MRTR mid-call user input. + * + * Covers, against a REAL fake MCP server reached through a real `McpManager` + * and a real `Orchestrator` turn: + * + * 1. The turn short-circuits on a parked `input_required` result, and the + * `done` event carries `pendingMcpInput` — including the server attribution. + * 2. `pendingUserChoice` is the deterministic winner when both are pending in + * the SAME tool batch, and the losing MCP record stays replayable. + * 3. The full two-turn round trip: the card answer replays the call as an + * orchestrator-driven FORCED call, the collected values reach the server + * verbatim, and the reply envelope never reaches the wire or the log. + * 4. Regression: an ordinary turn and an ordinary `ask_user_choice` turn are + * unaffected, since the short-circuit path is shared. + * + * Tests labelled MUTATION CHECK were verified by breaking the invariant, + * rebuilding, and confirming this assertion turns red. + */ +import { after, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { + createServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { Server as McpSdkServer } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import type { + LlmProvider, + LlmRequest, + LlmResponse, + LlmStreamEvent, +} from '@omadia/llm-provider'; +import type { ChatStreamEvent, PendingMcpInputCard } from '@omadia/channel-sdk'; +import { + AskUserChoiceTool, + InMemoryPendingMcpInputStore, + MCP_INPUT_REQUIRED_SENTINEL_PREFIX, + McpManager, + NativeToolRegistry, + Orchestrator, + REPLAY_ARG_KEY, + formatMcpInputReply, + mcpNativeHandler, + type McpInputReplayer, + type McpServerConfig, +} from '@omadia/orchestrator'; + +// ── fake MCP server ───────────────────────────────────────────────────────── + +/** Arguments the server saw, so replay assertions read the SERVER's view. */ +const serverArgs: Array> = []; + +function buildMcpServerInstance(): McpSdkServer { + const mcp = new McpSdkServer( + { name: 'fake-crm', version: '0.0.0' }, + { capabilities: { tools: {} } }, + ); + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [{ name: 'create_ticket', inputSchema: { type: 'object' as const } }], + })); + mcp.setRequestHandler(CallToolRequestSchema, async (request) => { + const args = (request.params.arguments ?? {}) as Record; + serverArgs.push(args); + const answers = args[REPLAY_ARG_KEY]; + if (answers !== undefined && answers !== null && typeof answers === 'object') { + const responses = answers as Record; + return { + content: [ + { + type: 'text' as const, + text: `Ticket TCK-77 für ${String(responses['customerNumber'])} angelegt (subject=${String(args['subject'])})`, + }, + ], + }; + } + return { + content: [{ type: 'text' as const, text: 'Angaben fehlen.' }], + resultType: 'input_required', + inputRequests: [ + { name: 'customerNumber', label: 'Kundennummer' }, + { name: 'pin', label: 'PIN', secret: true }, + ], + message: 'Bitte Kundennummer und PIN angeben.', + } as never; + }); + return mcp; +} + +async function startFakeMcpServer(): Promise<{ url: string; close(): Promise }> { + const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + const mcp = buildMcpServerInstance(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on('close', () => { + void transport.close().catch(() => {}); + void mcp.close().catch(() => {}); + }); + await mcp.connect(transport); + if (req.method === 'POST') { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8'); + await transport.handleRequest(req, res, raw.length > 0 ? JSON.parse(raw) : undefined); + return; + } + await transport.handleRequest(req, res); + }; + const http: HttpServer = createServer((req, res) => { + void handle(req, res).catch(() => { + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + }); + const sockets = new Set(); + http.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => http.listen(0, '127.0.0.1', () => resolve())); + const { port } = http.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/mcp`, + async close() { + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await new Promise((resolve) => http.close(() => resolve())); + }, + }; +} + +const fake = await startFakeMcpServer(); +after(() => fake.close()); + +const CFG: McpServerConfig = { + id: '00000000-0000-4000-8000-000000000544', + name: 'Kunden-CRM', + transport: 'http', + endpoint: fake.url, +}; + +// ── scripted provider ─────────────────────────────────────────────────────── + +const providerCapabilities = { + tools: true, + vision: true, + streaming: true, + promptCaching: true, + forcedToolChoice: true, + parallelToolCalls: true, +} as const; + +function fakeStreamProvider( + streams: LlmStreamEvent[][], + seenRequests: LlmRequest[], +): LlmProvider { + let idx = 0; + return { + id: 'anthropic', + capabilities: providerCapabilities, + // The BUFFERED path (`runTurn`) calls `complete()`, the streaming path + // calls `stream()`. Both are served from the same script so a single + // scenario definition covers each path identically. + complete: async (req: LlmRequest): Promise => { + seenRequests.push(req); + if (idx >= streams.length) { + throw new Error(`no scripted stream for provider call ${String(idx + 1)}`); + } + const events = streams[idx]!; + idx += 1; + const final = events.at(-1) as { type: string; response: LlmResponse }; + return final.response; + }, + stream: (req: LlmRequest): AsyncIterable => { + seenRequests.push(req); + if (idx >= streams.length) { + throw new Error(`no scripted stream for provider call ${String(idx + 1)}`); + } + const events = streams[idx]!; + idx += 1; + return { + async *[Symbol.asyncIterator]() { + for (const ev of events) yield ev; + }, + }; + }, + classifyError: () => ({ retryable: false, kind: 'other' as const }), + } as unknown as LlmProvider; +} + +function toolCallStream( + calls: Array<{ id: string; name: string; input: unknown }>, +): LlmStreamEvent[] { + return [ + { + type: 'final', + response: { + content: calls.map((c) => ({ + type: 'tool_call', + id: c.id, + name: c.name, + input: c.input, + })), + finishReason: 'tool_calls', + providerFinishReason: 'tool_use', + model: 'test', + usage: { inputTokens: 50, outputTokens: 4, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + } as LlmStreamEvent, + ]; +} + +function textStream(text: string): LlmStreamEvent[] { + return [ + { type: 'text_delta', text }, + { + type: 'final', + response: { + content: [{ type: 'text', text }], + finishReason: 'stop', + providerFinishReason: 'end_turn', + model: 'test', + usage: { inputTokens: 100, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + } as LlmStreamEvent, + ]; +} + +const MCP_TOOL_NAME = 'mcp__Kunden_CRM__create_ticket'; + +interface Harness { + readonly orchestrator: Orchestrator; + readonly store: InMemoryPendingMcpInputStore; + readonly seenRequests: LlmRequest[]; + readonly replayCalls: Array<{ toolName: string; responses: Record }>; +} + +function harness( + streams: LlmStreamEvent[][], + opts?: { readonly withChoiceTool?: boolean; readonly noReplayer?: boolean }, +): Harness { + const store = new InMemoryPendingMcpInputStore(); + const manager = new McpManager({ pendingInput: store }); + const registry = new NativeToolRegistry(); + registry.register(MCP_TOOL_NAME, { + handler: mcpNativeHandler(manager, CFG, 'create_ticket'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + spec: { + name: MCP_TOOL_NAME, + description: 'Create a CRM ticket.', + input_schema: { type: 'object' as const, properties: {}, required: [] }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + agentId: 'mcp-test', + }); + const seenRequests: LlmRequest[] = []; + const replayCalls: Array<{ toolName: string; responses: Record }> = []; + const replayer: McpInputReplayer = { + replay: async (record, inputResponses) => { + replayCalls.push({ toolName: record.toolName, responses: inputResponses }); + return manager.callTool(CFG, record.toolName, { + ...record.originalArgs, + [REPLAY_ARG_KEY]: inputResponses, + }); + }, + }; + const orchestrator = new Orchestrator({ + provider: fakeStreamProvider(streams, seenRequests), + model: 'test', + maxTokens: 1024, + maxToolIterations: 5, + domainTools: [], + nativeToolRegistry: registry, + pendingMcpInput: store, + ...(opts?.noReplayer ? {} : { mcpInputReplay: replayer }), + ...(opts?.withChoiceTool ? { askUserChoiceTool: new AskUserChoiceTool() } : {}), + }); + return { orchestrator, store, seenRequests, replayCalls }; +} + +async function runStream( + orchestrator: Orchestrator, + userMessage: string, + sessionScope?: string, + userId?: string, +): Promise { + const events: ChatStreamEvent[] = []; + for await (const ev of orchestrator.chatStream({ + userMessage, + ...(sessionScope !== undefined ? { sessionScope } : {}), + ...(userId !== undefined ? { userId } : {}), + })) { + events.push(ev); + } + return events; +} + +function doneEvent(events: ChatStreamEvent[]): { + answer: string; + pendingMcpInput?: PendingMcpInputCard; + pendingUserChoice?: unknown; +} { + const done = events.find((e) => e.type === 'done'); + assert.ok(done, 'no done event'); + return done as never; +} + +/** Every user-role text the provider actually saw, flattened. */ +function wireUserText(requests: readonly LlmRequest[]): string { + const parts: string[] = []; + for (const req of requests) { + for (const m of (req.messages ?? []) as Array<{ role: string; content: unknown }>) { + if (m.role !== 'user') continue; + if (typeof m.content === 'string') parts.push(m.content); + else if (Array.isArray(m.content)) { + for (const block of m.content as Array<{ type?: string; text?: string }>) { + if (typeof block.text === 'string') parts.push(block.text); + } + } + } + } + return parts.join('\n'); +} + +// ── 1. short-circuit ──────────────────────────────────────────────────────── + +describe('MCP input_required short-circuits the turn (#544 W2-1)', () => { + it('MUTATION CHECK: ends the turn and reports pendingMcpInput without a second model call', async () => { + const h = harness([ + toolCallStream([{ id: 'tu-1', name: MCP_TOOL_NAME, input: { subject: 'Drucker' } }]), + // Deliberately scripted so a SECOND provider call is possible. If the + // short-circuit is removed the orchestrator consumes it and `done` has no + // `pendingMcpInput` — this asserts the turn ENDED, not merely that a field + // is populated. + textStream('sollte nie erreicht werden'), + ]); + const events = await runStream(h.orchestrator, 'Ticket anlegen', 'sess-1', 'u1'); + const done = doneEvent(events); + assert.ok(done.pendingMcpInput, 'no pendingMcpInput on the done event'); + assert.equal(h.seenRequests.length, 1, 'the model was called again after the park'); + assert.notEqual(done.answer, 'sollte nie erreicht werden'); + }); + + it('MUTATION CHECK: the card names the asking server', async () => { + const h = harness([ + toolCallStream([{ id: 'tu-1', name: MCP_TOOL_NAME, input: {} }]), + textStream('x'), + ]); + const card = doneEvent( + await runStream(h.orchestrator, 'Ticket', 'sess-1', 'u1'), + ).pendingMcpInput; + assert.ok(card); + // Mandatory attribution: a hostile server must not be able to render a + // credential prompt that looks like omadia's own UI. Dropping `serverName` + // from `toPendingMcpInputCard` turns this red. + assert.equal(card.serverName, 'Kunden-CRM'); + assert.equal(card.serverId, CFG.id); + assert.equal(card.toolName, 'create_ticket'); + assert.equal(card.prompt, 'Bitte Kundennummer und PIN angeben.'); + assert.deepEqual( + card.fields.map((f) => f.name), + ['customerNumber', 'pin'], + ); + assert.equal(card.fields[1]?.secret, true); + assert.ok(card.correlationId.length > 0); + // The card must NOT leak the original arguments back to the channel. + assert.equal((card as Record)['originalArgs'], undefined); + }); + + it('MUTATION CHECK: the model never sees a fabricated tool result, only the sentinel', async () => { + const h = harness([ + toolCallStream([{ id: 'tu-1', name: MCP_TOOL_NAME, input: {} }]), + textStream('x'), + ]); + await runStream(h.orchestrator, 'Ticket', 'sess-1', 'u1'); + // The sentinel rides the tool_result of the SAME batch, so it is in the + // messages the (never-issued) next request would have carried. Asserting the + // orchestrator did not instead hand the model the server's "Angaben fehlen." + // text as if the call had succeeded. + const events = await runStream( + harness([ + toolCallStream([{ id: 'tu-1', name: MCP_TOOL_NAME, input: {} }]), + textStream('x'), + ]).orchestrator, + 'Ticket', + 'sess-1', + 'u1', + ); + const results = events.filter((e) => e.type === 'tool_result') as Array<{ + output?: string; + }>; + assert.ok(results.length > 0, 'no tool_result event observed'); + assert.ok( + results.some((r) => (r.output ?? '').startsWith(MCP_INPUT_REQUIRED_SENTINEL_PREFIX)), + `expected the sentinel in a tool_result, saw: ${JSON.stringify(results)}`, + ); + }); + + it('degrades to an ordinary tool error when no replayer is wired', async () => { + // Store present, replayer absent → `buildOrchestrator` would not enable the + // path at all; constructing it directly proves the orchestrator still + // behaves (the card renders, the answer just cannot be replayed later). + const h = harness( + [toolCallStream([{ id: 'tu-1', name: MCP_TOOL_NAME, input: {} }]), textStream('x')], + { noReplayer: true }, + ); + const done = doneEvent(await runStream(h.orchestrator, 'Ticket', 'sess-1', 'u1')); + assert.ok(done.pendingMcpInput); + }); +}); + +// ── 2. deterministic winner ───────────────────────────────────────────────── + +describe('both pendings in one batch (#544 W2-1)', () => { + it('MUTATION CHECK: pendingUserChoice wins deterministically and the MCP record survives', async () => { + const h = harness( + [ + // ONE batch, both tools. Dispatch order must not decide the outcome. + toolCallStream([ + { id: 'tu-1', name: MCP_TOOL_NAME, input: { subject: 'A' } }, + { + id: 'tu-2', + name: 'ask_user_choice', + input: { question: 'Welches System?', options: [{ label: 'CRM' }, { label: 'ERP' }] }, + }, + ]), + textStream('x'), + ], + { withChoiceTool: true }, + ); + const done = doneEvent(await runStream(h.orchestrator, 'Ticket', 'sess-1', 'u1')); + // The winner, asserted in BOTH directions: a rule that merely "prefers" the + // choice card while still emitting the MCP card would pass a one-sided check. + assert.ok(done.pendingUserChoice, 'the choice card must win'); + assert.equal(done.pendingMcpInput, undefined, 'the MCP card must not also ship'); + // …and the loser is not destroyed: the parked call is still replayable, so + // the user can resolve the clarification and continue. + assert.equal(h.store.size(), 1, 'the losing MCP record was discarded'); + }); + + it('MUTATION CHECK: reversing the batch order does not change the winner', async () => { + const h = harness( + [ + toolCallStream([ + { + id: 'tu-1', + name: 'ask_user_choice', + input: { question: 'Welches System?', options: [{ label: 'CRM' }, { label: 'ERP' }] }, + }, + { id: 'tu-2', name: MCP_TOOL_NAME, input: {} }, + ]), + textStream('x'), + ], + { withChoiceTool: true }, + ); + const done = doneEvent(await runStream(h.orchestrator, 'Ticket', 'sess-1', 'u1')); + // Same outcome as the previous test with the batch reversed — that is what + // "deterministic" means here. A `??`-style "whichever drained first" rule + // turns exactly one of these two red. + assert.ok(done.pendingUserChoice); + assert.equal(done.pendingMcpInput, undefined); + }); +}); + +// ── 3. the two-turn round trip ────────────────────────────────────────────── + +describe('two-turn replay through the orchestrator (#544 W2-1)', () => { + it('MUTATION CHECK: the collected values reach the server verbatim in a later turn', async () => { + serverArgs.length = 0; + const h = harness([ + toolCallStream([{ id: 'tu-1', name: MCP_TOOL_NAME, input: { subject: 'Drucker kaputt' } }]), + // Turn 2: no tool call at all. The replay is orchestrator-driven, so the + // model only narrates — if the replay had been left to the model, this + // pure-text script would produce NO server call and the test would fail. + textStream('Ticket TCK-77 ist angelegt.'), + ]); + + const card = doneEvent( + await runStream(h.orchestrator, 'Ticket anlegen', 'sess-1', 'u1'), + ).pendingMcpInput; + assert.ok(card); + + const envelope = formatMcpInputReply({ + correlationId: card.correlationId, + inputResponses: { customerNumber: 'K-1234', pin: '9876' }, + }); + const done2 = doneEvent(await runStream(h.orchestrator, envelope, 'sess-1', 'u1')); + + // The FORCED call happened, exactly once, with the right arguments… + assert.equal(h.replayCalls.length, 1); + assert.deepEqual(h.replayCalls[0], { + toolName: 'create_ticket', + responses: { customerNumber: 'K-1234', pin: '9876' }, + }); + // …asserted at the SERVER, not at our own call site: original args survive + // and the collected values arrive unmangled. + assert.deepEqual(serverArgs.at(-1), { + subject: 'Drucker kaputt', + inputResponses: { customerNumber: 'K-1234', pin: '9876' }, + }); + assert.equal(done2.answer, 'Ticket TCK-77 ist angelegt.'); + assert.equal(done2.pendingMcpInput, undefined, 'the replay must not re-park'); + }); + + it('MUTATION CHECK: the replayed result reaches the model, and the envelope never does', async () => { + const h = harness([ + toolCallStream([{ id: 'tu-1', name: MCP_TOOL_NAME, input: { subject: 'Drucker kaputt' } }]), + textStream('fertig'), + ]); + const card = doneEvent( + await runStream(h.orchestrator, 'Ticket anlegen', 'sess-1', 'u1'), + ).pendingMcpInput; + assert.ok(card); + h.seenRequests.length = 0; + + await runStream( + h.orchestrator, + formatMcpInputReply({ + correlationId: card.correlationId, + inputResponses: { customerNumber: 'K-1234', pin: 'geheim-9876' }, + }), + 'sess-1', + 'u1', + ); + const wire = wireUserText(h.seenRequests); + + // The model must be able to narrate the outcome: the replayed result is on + // the wire. Dropping `withMcpInputNote` turns this red. + assert.ok(wire.includes('TCK-77'), `replayed result missing from the wire: ${wire}`); + assert.ok(wire.includes('Kunden-CRM'), 'the server attribution is missing'); + // The machine envelope must NOT be: it is replaced by a human label before + // any downstream reader (wire, session log, memory, transcript) sees it. + assert.ok(!wire.includes('__mcp_input_reply__'), `raw envelope reached the wire: ${wire}`); + assert.ok(wire.includes('Eingaben übermittelt'), 'the human label is missing'); + // And the SECRET the user typed must not be echoed into the prompt by the + // note — only the field NAMES are. + assert.ok(!wire.includes('geheim-9876'), `a secret value reached the wire: ${wire}`); + assert.ok(wire.includes('customerNumber'), 'field names should be listed'); + }); + + it('MUTATION CHECK: a stale or foreign correlationId is refused, not looked up by id', async () => { + const h = harness([textStream('kann ich nicht mehr')]); + const events = await runStream( + h.orchestrator, + formatMcpInputReply({ + correlationId: 'never-parked', + inputResponses: { customerNumber: 'K-1' }, + }), + 'sess-1', + 'u1', + ); + doneEvent(events); + // No replay may fire for an id that was never parked under this identity. + // Widening the lookup to "by correlationId" would fire one — and that is + // exactly the #445 cross-user shape. + assert.equal(h.replayCalls.length, 0); + assert.ok(wireUserText(h.seenRequests).includes('nicht mehr gültig')); + }); + + it('MUTATION CHECK: a card parked for one session cannot be answered from another', async () => { + const h = harness([ + toolCallStream([{ id: 'tu-1', name: MCP_TOOL_NAME, input: {} }]), + textStream('x'), + ]); + const card = doneEvent( + await runStream(h.orchestrator, 'Ticket', 'sess-VICTIM', 'u1'), + ).pendingMcpInput; + assert.ok(card); + + // Same user, same correlationId, DIFFERENT session — the store key is the + // triple, so this misses. Keying on `sessionScope` alone (or on the id + // alone) turns this red, which is the whole point of #445. + await runStream( + h.orchestrator, + formatMcpInputReply({ + correlationId: card.correlationId, + inputResponses: { customerNumber: 'K-STOLEN' }, + }), + 'sess-ATTACKER', + 'u1', + ); + assert.equal(h.replayCalls.length, 0, 'a cross-session replay fired'); + + // Same session, DIFFERENT user — also a miss. + await runStream( + h.orchestrator, + formatMcpInputReply({ + correlationId: card.correlationId, + inputResponses: { customerNumber: 'K-STOLEN' }, + }), + 'sess-VICTIM', + 'u2', + ); + assert.equal(h.replayCalls.length, 0, 'a cross-user replay fired'); + }); +}); + +// ── 3b. the BUFFERED path (runTurn) ───────────────────────────────────────── +// +// `chatInContextInner` carries a hand-mirrored copy of the streaming +// short-circuit, the winner rule and the envelope normalisation. A mutation run +// proved these are NOT covered by the streaming tests above: breaking only the +// buffered copy left the whole suite green. Non-streaming callers (Teams and +// every `chat()`/`runTurn()` consumer) go through exactly this code, so it gets +// its own coverage rather than trusting the mirror to stay in sync. + +async function runBuffered( + orchestrator: Orchestrator, + userMessage: string, + sessionScope?: string, + userId?: string, +): Promise<{ answer: string; pendingMcpInput?: PendingMcpInputCard; pendingUserChoice?: unknown }> { + return orchestrator.runTurn({ + userMessage, + ...(sessionScope !== undefined ? { sessionScope } : {}), + ...(userId !== undefined ? { userId } : {}), + }) as never; +} + +describe('buffered path — runTurn (#544 W2-1)', () => { + it('MUTATION CHECK: short-circuits and reports pendingMcpInput', async () => { + const h = harness([ + toolCallStream([{ id: 'tu-1', name: MCP_TOOL_NAME, input: { subject: 'Drucker' } }]), + textStream('sollte nie erreicht werden'), + ]); + const result = await runBuffered(h.orchestrator, 'Ticket anlegen', 'sess-1', 'u1'); + assert.ok(result.pendingMcpInput, 'no pendingMcpInput from runTurn'); + assert.equal(result.pendingMcpInput.serverName, 'Kunden-CRM'); + assert.equal(h.seenRequests.length, 1, 'the model was called again after the park'); + }); + + it('MUTATION CHECK: pendingUserChoice wins here too', async () => { + const h = harness( + [ + toolCallStream([ + { id: 'tu-1', name: MCP_TOOL_NAME, input: {} }, + { + id: 'tu-2', + name: 'ask_user_choice', + input: { question: 'Welches System?', options: [{ label: 'CRM' }, { label: 'ERP' }] }, + }, + ]), + textStream('x'), + ], + { withChoiceTool: true }, + ); + const result = await runBuffered(h.orchestrator, 'Ticket', 'sess-1', 'u1'); + // The buffered copy of the winner rule, pinned independently of the + // streaming one — a change applied to only one of the two turns this red. + assert.ok(result.pendingUserChoice, 'the choice card must win on the buffered path'); + assert.equal(result.pendingMcpInput, undefined); + assert.equal(h.store.size(), 1, 'the losing MCP record was discarded'); + }); + + it('MUTATION CHECK: replays on the buffered path, and the envelope never reaches the wire', async () => { + serverArgs.length = 0; + const h = harness([ + toolCallStream([{ id: 'tu-1', name: MCP_TOOL_NAME, input: { subject: 'Drucker kaputt' } }]), + textStream('Ticket ist angelegt.'), + ]); + const card = (await runBuffered(h.orchestrator, 'Ticket anlegen', 'sess-1', 'u1')) + .pendingMcpInput; + assert.ok(card); + h.seenRequests.length = 0; + + const second = await runBuffered( + h.orchestrator, + formatMcpInputReply({ + correlationId: card.correlationId, + inputResponses: { customerNumber: 'K-1234', pin: 'geheim-9876' }, + }), + 'sess-1', + 'u1', + ); + + assert.equal(h.replayCalls.length, 1, 'the buffered path did not replay'); + assert.deepEqual(serverArgs.at(-1), { + subject: 'Drucker kaputt', + inputResponses: { customerNumber: 'K-1234', pin: 'geheim-9876' }, + }); + assert.equal(second.answer, 'Ticket ist angelegt.'); + const wire = wireUserText(h.seenRequests); + assert.ok(wire.includes('TCK-77'), `replayed result missing from the wire: ${wire}`); + // The envelope normalisation in `runTurn` — its own code, its own test. + assert.ok(!wire.includes('__mcp_input_reply__'), `raw envelope reached the wire: ${wire}`); + assert.ok(wire.includes('Eingaben übermittelt')); + assert.ok(!wire.includes('geheim-9876'), 'a secret value reached the wire'); + }); + + it('a cross-session answer is refused on the buffered path', async () => { + const h = harness([ + toolCallStream([{ id: 'tu-1', name: MCP_TOOL_NAME, input: {} }]), + textStream('x'), + ]); + const card = (await runBuffered(h.orchestrator, 'Ticket', 'sess-VICTIM', 'u1')) + .pendingMcpInput; + assert.ok(card); + await runBuffered( + h.orchestrator, + formatMcpInputReply({ + correlationId: card.correlationId, + inputResponses: { customerNumber: 'K-STOLEN' }, + }), + 'sess-ATTACKER', + 'u1', + ); + assert.equal(h.replayCalls.length, 0); + }); +}); + +// ── 4. regression: the shared short-circuit path ──────────────────────────── + +describe('regression — the shared short-circuit path (#544 W2-1)', () => { + it('an ordinary turn is unaffected', async () => { + const h = harness([textStream('Hallo!')]); + const done = doneEvent(await runStream(h.orchestrator, 'Hi', 'sess-1', 'u1')); + assert.equal(done.answer, 'Hallo!'); + assert.equal(done.pendingMcpInput, undefined); + assert.equal(h.store.size(), 0); + }); + + it('an ordinary ask_user_choice turn still short-circuits with only a choice card', async () => { + const h = harness( + [ + toolCallStream([ + { + id: 'tu-1', + name: 'ask_user_choice', + input: { question: 'Welches Modul?', options: [{ label: 'Sales' }, { label: 'POS' }] }, + }, + ]), + textStream('nie erreicht'), + ], + { withChoiceTool: true }, + ); + const done = doneEvent(await runStream(h.orchestrator, 'Frage', 'sess-1', 'u1')); + assert.ok(done.pendingUserChoice); + assert.equal(done.pendingMcpInput, undefined); + assert.equal(h.seenRequests.length, 1); + }); + + it('a successful MCP call is untouched by the MRTR path', async () => { + // Same tool, but the call already carries `inputResponses`, so the fake + // server answers normally: the park branch must not fire on a success. + const h = harness([ + toolCallStream([ + { + id: 'tu-1', + name: MCP_TOOL_NAME, + input: { subject: 'S', [REPLAY_ARG_KEY]: { customerNumber: 'K-9' } }, + }, + ]), + textStream('erledigt'), + ]); + const done = doneEvent(await runStream(h.orchestrator, 'Ticket', 'sess-1', 'u1')); + assert.equal(done.answer, 'erledigt'); + assert.equal(done.pendingMcpInput, undefined); + assert.equal(h.store.size(), 0); + assert.equal(h.seenRequests.length, 2, 'the turn should have continued normally'); + }); +}); diff --git a/middleware/test/orchestrator/mcpUserKeyProducer.test.ts b/middleware/test/orchestrator/mcpUserKeyProducer.test.ts new file mode 100644 index 00000000..0e1d511b --- /dev/null +++ b/middleware/test/orchestrator/mcpUserKeyProducer.test.ts @@ -0,0 +1,560 @@ +/** + * W4-1 — the missing `mcpUserKey` PRODUCER. + * + * Migration 0031 made MCP delegation explicit per server; `per_user` is the + * default for every newly created one. The consumer side shipped in W0-1: + * `resolveMcpUserKey` reads `turnContext.current()?.mcpUserKey` and fails + * closed when nothing resolves. The PRODUCER never did — no chat path and no + * channel path ever set the field — so every `per_user` server was dead from + * chat and from Teams/Telegram, with the audit row recording `unresolved`. + * + * These tests drive the two producers through the REAL consumer functions + * (`resolveMcpUserKey` / `auditIdentity` / `delegationBlockedMessage`), wired + * exactly as `src/index.ts` wires them into `McpManager.auth`, so a green test + * means the production decision — send a token, or refuse — comes out right. + * + * The probe reads the context where production reads it: on the streaming path, + * only after the agent's generator has already yielded and been resumed by the + * route's consumer loop. + * + * HONEST SCOPE NOTE. Swapping the streaming route's `turnContext.runGenerator` + * for `turnContext.enter` does NOT turn these red — it was tried, and they + * stayed green. `enterWith` at the top of an Express handler binds the store to + * that handler's own async chain, and the `for await` consumer lives in that + * same chain, so the value still arrives. `runGenerator` is still the correct + * call there (it does not leak the scope into the consumer, and it drives the + * generator's teardown inside the scope on a client abort) but the evidence for + * THAT property is `turnContextPropagation.test.ts`'s consumer-leak assertion, + * not these tests. What these tests do prove is the PRODUCER: delete the + * `mcpUserKey` line and they go red with the delegation-blocked message. + */ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import type { AddressInfo } from 'node:net'; +import type { Server } from 'node:http'; + +import express from 'express'; + +import type { + LlmProvider, + LlmResponse, + LlmStreamEvent, +} from '@omadia/llm-provider'; +import type { + ChatAgent, + ChatTurnInput, + ChatTurnResult, +} from '@omadia/orchestrator'; +import { + NativeToolRegistry, + Orchestrator, + turnContext, +} from '@omadia/orchestrator'; +import type { KnowledgeGraph } from '@omadia/plugin-api'; + +import { createChatRouter } from '../../src/routes/chat.js'; +import { + UNRESOLVED_IDENTITY, + auditIdentity, + delegationBlockedMessage, + resolveMcpUserKey, +} from '../../src/services/mcpDelegation.js'; + +// ── the production decision, reproduced ───────────────────────────────────── + +const PER_USER = { delegation: 'per_user' } as const; +const SERVICE = { delegation: 'service' } as const; +const SERVER_NAME = 'Kunden-CRM'; + +/** + * What `McpManager.auth` does with the ambient turn context, condensed. Mirrors + * `src/index.ts`: `getToken` refuses when `resolveMcpUserKey` returns null, + * `resolveIdentity` writes `auditIdentity` to `mcp_call_log`, and + * `onAuthFailure` explains with `delegationBlockedMessage`. + */ +interface McpDecision { + /** The key a token would be looked up under, or null = send nothing. */ + readonly userKey: string | null; + /** What lands in the `mcp_call_log` row. */ + readonly audited: string; + /** The operator-facing refusal, or null when the call proceeds. */ + readonly blockedWith: string | null; +} + +function decide(server: { delegation: 'per_user' | 'service' }): McpDecision { + const candidate = turnContext.current()?.mcpUserKey; + const userKey = resolveMcpUserKey(server, candidate); + return { + userKey, + audited: auditIdentity(server, candidate), + blockedWith: userKey === null ? delegationBlockedMessage(SERVER_NAME) : null, + }; +} + +/** Both delegation modes, decided in the same turn scope. */ +interface Probe { + readonly perUser: McpDecision; + readonly service: McpDecision; +} + +const probe = (): Probe => ({ perUser: decide(PER_USER), service: decide(SERVICE) }); + +// ── part 1: the HTTP chat routes ──────────────────────────────────────────── + +/** + * A ChatAgent that probes the ambient turn context the way a tool handler + * would. The streaming half yields BEFORE probing — that suspension is the + * whole point: it is where `enterWith` loses the store, and it is crossed on + * every real turn long before the first tool runs. + */ +function probingChatAgent(sink: Probe[]): ChatAgent { + return { + chat: (_input: ChatTurnInput): Promise => { + sink.push(probe()); + return Promise.resolve({ kind: 'message', text: 'ok' } as unknown as ChatTurnResult); + }, + // eslint-disable-next-line @typescript-eslint/require-await + chatStream: async function* () { + yield { type: 'iteration_start', iteration: 1 }; + // …the consumer processes that event OUTSIDE the turn scope, and only + // then resumes us. Anything read after this line proves the scope + // survived a real generator suspension. + await Promise.resolve(); + sink.push(probe()); + yield { type: 'done', text: 'ok' }; + }, + } as unknown as ChatAgent; +} + +interface HttpHarness { + readonly baseUrl: string; + readonly probes: Probe[]; + close(): Promise; +} + +/** Mounts the real chat router behind a session-injecting middleware — the + * same shape `requireAuth` produces (`req.session = claims`). */ +async function mountChat(): Promise { + const probes: Probe[] = []; + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + const sub = req.header('x-test-session-sub'); + if (sub !== undefined && sub !== '') { + (req as express.Request).session = { sub } as NonNullable; + } + next(); + }); + const agent = probingChatAgent(probes); + app.use( + '/api', + createChatRouter({ + resolveChatAgent: () => agent, + getDefaultSlug: () => 'probe-agent', + }), + ); + const server: Server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', () => resolve())); + const { port } = server.address() as AddressInfo; + return { + baseUrl: `http://127.0.0.1:${port}/api`, + probes, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +async function postTurn( + h: HttpHarness, + path: '/chat' | '/chat/stream', + sub?: string, +): Promise { + const res = await fetch(`${h.baseUrl}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(sub ? { 'x-test-session-sub': sub } : {}), + }, + body: JSON.stringify({ message: 'los' }), + }); + assert.equal(res.status, 200, `${path} did not serve the turn`); + await res.text(); +} + +describe('W4-1 producer — HTTP chat routes', () => { + it('MUTATION CHECK: non-streaming turn reaches a per_user server AS the session identity', async () => { + const h = await mountChat(); + try { + await postTurn(h, '/chat', 'alice@example.com'); + assert.equal(h.probes.length, 1, 'the chat agent never ran'); + const { perUser } = h.probes[0]!; + assert.equal( + perUser.userKey, + 'alice@example.com', + 'per_user delegation resolved no identity on an authenticated HTTP turn', + ); + assert.equal(perUser.blockedWith, null, 'the turn was blocked despite a resolvable caller'); + assert.notEqual(perUser.audited, UNRESOLVED_IDENTITY); + } finally { + await h.close(); + } + }); + + it('MUTATION CHECK: STREAMING turn reaches a per_user server AS the session identity', async () => { + // Read after the generator has yielded once and been resumed — the point in + // the turn where any real tool (and so any MCP call) actually runs. + const h = await mountChat(); + try { + await postTurn(h, '/chat/stream', 'alice@example.com'); + assert.equal(h.probes.length, 1, 'the chat agent never ran'); + const { perUser } = h.probes[0]!; + assert.equal( + perUser.userKey, + 'alice@example.com', + 'per_user delegation resolved no identity on an authenticated streaming turn', + ); + assert.equal(perUser.blockedWith, null, 'the turn was blocked despite a resolvable caller'); + } finally { + await h.close(); + } + }); + + it('MUTATION CHECK: concurrent streaming turns never see each other`s identity', async () => { + // The #445-class hazard: one shared store, or a scope bound to the wrong + // async resource, shows up here as two turns agreeing on one identity. + const h = await mountChat(); + try { + await Promise.all([ + postTurn(h, '/chat/stream', 'alice@example.com'), + postTurn(h, '/chat/stream', 'bob@example.com'), + ]); + assert.equal(h.probes.length, 2, 'both turns must have run'); + const keys = h.probes.map((p) => p.perUser.userKey).sort(); + assert.deepEqual( + keys, + ['alice@example.com', 'bob@example.com'], + 'two concurrent streaming turns did not each keep their own identity', + ); + } finally { + await h.close(); + } + }); + + it('W0-1 preserved: an UNAUTHENTICATED turn still fails closed on per_user', async () => { + // The fix must not weaken the confused-deputy guard. No session ⇒ no + // identity ⇒ no token, and the refusal names the server. + const h = await mountChat(); + try { + await postTurn(h, '/chat'); + await postTurn(h, '/chat/stream'); + assert.equal(h.probes.length, 2, 'both turns must have run'); + for (const p of h.probes) { + assert.equal(p.perUser.userKey, null, 'an unauthenticated turn resolved an identity'); + assert.equal(p.perUser.audited, UNRESOLVED_IDENTITY); + assert.equal(p.perUser.blockedWith, delegationBlockedMessage(SERVER_NAME)); + } + } finally { + await h.close(); + } + }); + + it('W0-1 preserved: the client-controlled x-user-id header is NOT an identity', async () => { + // `resolveUserId` accepts this header, and it reaches the orchestrator as + // `input.userId`. If either producer keyed MCP tokens on it, any caller + // could act as any user. Belt and braces: assert it does not. + const h = await mountChat(); + try { + const res = await fetch(`${h.baseUrl}/chat`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-user-id': 'alice@example.com' }, + body: JSON.stringify({ message: 'los' }), + }); + assert.equal(res.status, 200); + await res.text(); + assert.equal( + h.probes[0]?.perUser.userKey, + null, + 'a client-supplied x-user-id header was accepted as the MCP identity', + ); + } finally { + await h.close(); + } + }); + + it('service delegation is unaffected, authenticated or not', async () => { + const h = await mountChat(); + try { + await postTurn(h, '/chat', 'alice@example.com'); + await postTurn(h, '/chat/stream', 'alice@example.com'); + await postTurn(h, '/chat'); + await postTurn(h, '/chat/stream'); + assert.equal(h.probes.length, 4); + for (const p of h.probes) { + assert.equal(p.service.userKey, 'operator', 'service delegation stopped resolving'); + assert.equal(p.service.audited, 'operator'); + assert.equal(p.service.blockedWith, null, 'a service server was blocked'); + } + } finally { + await h.close(); + } + }); +}); + +// ── part 2: the channel path (orchestrator, option (b)) ───────────────────── + +const providerCapabilities = { + tools: true, + vision: true, + streaming: true, + promptCaching: true, + forcedToolChoice: true, + parallelToolCalls: true, +} as const; + +function fakeProvider(streams: LlmStreamEvent[][]): LlmProvider { + let idx = 0; + const take = (): LlmStreamEvent[] => { + if (idx >= streams.length) { + throw new Error(`no scripted stream for provider call ${String(idx + 1)}`); + } + const events = streams[idx]!; + idx += 1; + return events; + }; + return { + id: 'anthropic', + capabilities: providerCapabilities, + complete: async (): Promise => { + const events = take(); + return (events.at(-1) as { type: string; response: LlmResponse }).response; + }, + stream: (): AsyncIterable => { + const events = take(); + return { + async *[Symbol.asyncIterator]() { + for (const ev of events) yield ev; + }, + }; + }, + classifyError: () => ({ retryable: false, kind: 'other' as const }), + } as unknown as LlmProvider; +} + +const PROBE_TOOL = 'probe_mcp_identity'; + +function toolCallStream(): LlmStreamEvent[] { + return [ + { + type: 'final', + response: { + content: [{ type: 'tool_call', id: 'tu-1', name: PROBE_TOOL, input: {} }], + finishReason: 'tool_calls', + providerFinishReason: 'tool_use', + model: 'test', + usage: { inputTokens: 50, outputTokens: 4, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + } as LlmStreamEvent, + ]; +} + +function textStream(): LlmStreamEvent[] { + return [ + { type: 'text_delta', text: 'fertig' }, + { + type: 'final', + response: { + content: [{ type: 'text', text: 'fertig' }], + finishReason: 'stop', + providerFinishReason: 'end_turn', + model: 'test', + usage: { inputTokens: 100, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + } as LlmStreamEvent, + ]; +} + +const CANONICAL_UUID = '3f5a6b1c-0000-4000-8000-00000000beef'; +const CANONICAL_UUID_B = '3f5a6b1c-0000-4000-8000-0000000000b0'; + +/** Cluster roots by channel-native id, so two channel users are genuinely two + * different humans rather than one id the fake hands out twice. */ +const CLUSTER_BY_CHANNEL_USER: Record = { + 'aad-oid-1234': CANONICAL_UUID, + 'aad-oid-5678': CANONICAL_UUID_B, +}; + +/** The one KG method `resolveTurnOwnerIdentity` calls. */ +function fakeKnowledgeGraph(): KnowledgeGraph { + return { + resolveOrCreateChannelIdentity: async (ingest: { channelUserId: string }) => + Promise.resolve({ + omadiaUserId: CLUSTER_BY_CHANNEL_USER[ingest.channelUserId] ?? CANONICAL_UUID, + }), + } as unknown as KnowledgeGraph; +} + +interface ChannelHarness { + readonly orchestrator: Orchestrator; + readonly probes: Probe[]; +} + +function channelHarness(opts?: { readonly withGraph?: boolean }): ChannelHarness { + const probes: Probe[] = []; + const registry = new NativeToolRegistry(); + registry.register(PROBE_TOOL, { + handler: async () => { + probes.push(probe()); + return Promise.resolve('ok'); + }, + spec: { + name: PROBE_TOOL, + description: 'Decides the MCP identity from the ambient turn context.', + input_schema: { type: 'object' as const, properties: {}, required: [] }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + agentId: 'probe', + }); + const orchestrator = new Orchestrator({ + provider: fakeProvider([toolCallStream(), textStream()]), + model: 'test', + maxTokens: 1024, + maxToolIterations: 5, + domainTools: [], + nativeToolRegistry: registry, + agentId: 'probe-agent', + ...(opts?.withGraph === false ? {} : { knowledgeGraph: fakeKnowledgeGraph() }), + }); + return { orchestrator, probes }; +} + +/** A Teams-shaped turn, exactly as `createOrchestratorDispatcher` builds it: + * raw channel-native `userId` PLUS the typed, server-attested + * `channelIdentity` derived from the adapter's authenticated `userRef`. */ +const teamsTurn = { + userMessage: 'los', + sessionScope: 'teams-19:abc', + userId: 'aad-oid-1234', + channelIdentity: { channelKind: 'teams' as const, channelUserId: 'aad-oid-1234' }, +}; + +async function drainStream(h: ChannelHarness, input: object): Promise { + for await (const _ of h.orchestrator.chatStream(input as never)) { + // drain + } +} + +describe('W4-1 producer — channel turns (orchestrator, option (b))', () => { + it('MUTATION CHECK: a Teams turn reaches a per_user server AS the canonical omadia user', async () => { + // Channels go through `chatStream` via `createOrchestratorDispatcher`. + const h = channelHarness(); + await drainStream(h, teamsTurn); + assert.equal(h.probes.length, 1, 'the probe tool never ran'); + const { perUser } = h.probes[0]!; + assert.equal( + perUser.userKey, + CANONICAL_UUID, + 'a channel turn resolved no MCP identity — per_user servers stay dead on Teams', + ); + assert.equal(perUser.blockedWith, null, 'the channel turn was blocked despite a mapped user'); + assert.notEqual(perUser.audited, UNRESOLVED_IDENTITY); + }); + + it('MUTATION CHECK: the buffered path (runTurn) resolves it too', async () => { + const h = channelHarness(); + await h.orchestrator.runTurn(teamsTurn as never); + assert.equal(h.probes.length, 1, 'the probe tool never ran'); + assert.equal(h.probes[0]!.perUser.userKey, CANONICAL_UUID); + assert.equal(h.probes[0]!.perUser.blockedWith, null); + }); + + it('W0-1 preserved: an UNRESOLVABLE channel identity still fails closed', async () => { + // No KnowledgeGraph ⇒ `resolveTurnOwnerIdentity` returns undefined rather + // than guessing with the raw channel-native id. No substitute is invented. + const h = channelHarness({ withGraph: false }); + await drainStream(h, teamsTurn); + assert.equal(h.probes.length, 1); + assert.equal( + h.probes[0]!.perUser.userKey, + null, + 'an unresolvable channel user was given an identity anyway', + ); + assert.equal(h.probes[0]!.perUser.audited, UNRESOLVED_IDENTITY); + assert.equal(h.probes[0]!.perUser.blockedWith, delegationBlockedMessage(SERVER_NAME)); + }); + + it('W0-1 preserved: a turn with NO channelIdentity never borrows input.userId', async () => { + // This is the gate. Without `channelIdentity`, `resolveTurnOwnerIdentity` + // returns `input.userId` verbatim — and on the HTTP path that id can come + // straight from the client-supplied `x-user-id` header. Keying MCP tokens + // on it would re-open the confused deputy one door along. + const h = channelHarness(); + await drainStream(h, { + userMessage: 'los', + sessionScope: 'http-default', + userId: 'alice@example.com', + }); + assert.equal(h.probes.length, 1); + assert.equal( + h.probes[0]!.perUser.userKey, + null, + 'input.userId was accepted as the MCP identity without a channelIdentity', + ); + }); + + it('an outer scope (an HTTP route) still WINS over the channel resolution', async () => { + const h = channelHarness(); + await turnContext.run( + { turnId: 'outer-http-turn', turnDate: '2026-07-31', mcpUserKey: 'alice@example.com' }, + () => drainStream(h, teamsTurn), + ); + assert.equal(h.probes.length, 1); + assert.equal( + h.probes[0]!.perUser.userKey, + 'alice@example.com', + 'the orchestrator overrode an identity the route had already established', + ); + }); + + it('MUTATION CHECK: two INTERLEAVED channel turns from different users never cross identities', async () => { + // The #445 class applied to this producer, and the case the HTTP + // concurrency test does NOT reach: two channel turns advanced alternately + // at the generator level, so a shared store — or a scope bound to the wrong + // async resource — shows up as one identity serving both humans. That is + // the failure mode where user B's turn would reach an MCP server holding + // user A's token, which is strictly worse than failing closed. + const a = channelHarness(); + const b = channelHarness(); + const genA = a.orchestrator.chatStream(teamsTurn as never); + const genB = b.orchestrator.chatStream({ + ...teamsTurn, + sessionScope: 'teams-19:def', + userId: 'aad-oid-5678', + channelIdentity: { channelKind: 'teams' as const, channelUserId: 'aad-oid-5678' }, + } as never); + let doneA = false; + let doneB = false; + while (!doneA || !doneB) { + if (!doneA) doneA = (await genA.next()).done === true; + if (!doneB) doneB = (await genB.next()).done === true; + } + assert.equal(a.probes.length, 1, 'turn A never dispatched the probe'); + assert.equal(b.probes.length, 1, 'turn B never dispatched the probe'); + assert.equal(a.probes[0]!.perUser.userKey, CANONICAL_UUID); + assert.equal(b.probes[0]!.perUser.userKey, CANONICAL_UUID_B); + assert.notEqual( + a.probes[0]!.perUser.userKey, + b.probes[0]!.perUser.userKey, + 'two interleaved channel turns shared one MCP identity', + ); + }); + + it('service delegation is unaffected on channel turns', async () => { + const h = channelHarness(); + await drainStream(h, teamsTurn); + const hNoGraph = channelHarness({ withGraph: false }); + await drainStream(hNoGraph, teamsTurn); + for (const p of [...h.probes, ...hNoGraph.probes]) { + assert.equal(p.service.userKey, 'operator', 'service delegation stopped resolving'); + assert.equal(p.service.blockedWith, null, 'a service server was blocked'); + } + }); +}); diff --git a/middleware/test/orchestrator/timeoutHierarchy.test.ts b/middleware/test/orchestrator/timeoutHierarchy.test.ts new file mode 100644 index 00000000..3fc234c3 --- /dev/null +++ b/middleware/test/orchestrator/timeoutHierarchy.test.ts @@ -0,0 +1,155 @@ +/** + * W3-A / W4 — the tool-timeout knobs must stay COHERENT. + * + * There are three nested bounds around one MCP-backed tool dispatch: + * + * inner `OMADIA_MCP_CALL_TIMEOUT_MS` 60 s idle budget / request + * middle `OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS` 180 s absolute MCP ceiling + * outer `OMADIA_TOOL_DISPATCH_TIMEOUT_MS` 240 s per-tool dispatch + * + * The dispatch deadline defaulted to 120 s, i.e. INSIDE the 180 s MCP ceiling. + * A server streaming progress notifications (`resetTimeoutOnProgress`) for its + * full allowance was therefore aborted by the OUTER bound first — the outer + * schranke was tighter than the inner one, which is backwards. The model then + * saw a generic dispatch-deadline error rather than the MCP layer's own + * diagnosis, and no `mcp_call_log` failure row named the slow server. + * + * W4 closes the two ways that fix was defeated: + * + * 1. The invariant was asserted against a PER-ATTEMPT ceiling while `callTool` + * makes up to `MCP_CALL_MAX_ATTEMPTS` of them — real worst case 2 × 180 s = + * 360 s, above the 240 s outer bound. `resolveMcpCallTimeouts` now reports + * `worstCaseTotalMs` (retries share one budget), and that is what the + * invariant is stated against. + * 2. The invariant itself lived ONLY in this file, as a local helper nothing + * shipped ever called — so an env override re-created the inversion with + * fully green CI. It now lives in `assertTimeoutHierarchy()` and runs at + * boot; this file asserts the PRODUCTION function, not a copy of it. + */ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + MCP_CALL_MAX_ATTEMPTS, + assertTimeoutHierarchy, + resolveMcpCallTimeouts, + resolveToolDispatchTimeoutMs, +} from '@omadia/orchestrator'; + +const DISPATCH_ENV = 'OMADIA_TOOL_DISPATCH_TIMEOUT_MS'; +const MCP_TOTAL_ENV = 'OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS'; +const MCP_REQUEST_ENV = 'OMADIA_MCP_CALL_TIMEOUT_MS'; + +const originals = { + [DISPATCH_ENV]: process.env[DISPATCH_ENV], + [MCP_TOTAL_ENV]: process.env[MCP_TOTAL_ENV], + [MCP_REQUEST_ENV]: process.env[MCP_REQUEST_ENV], +}; + +afterEach(() => { + for (const [name, value] of Object.entries(originals)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +}); + +function clearEnv(): void { + for (const name of [DISPATCH_ENV, MCP_TOTAL_ENV, MCP_REQUEST_ENV]) { + delete process.env[name]; + } +} + +describe('tool-timeout hierarchy (W3-A / W4)', () => { + it('MUTATION CHECK: the shipped defaults order outer > mcp-worst-case > mcp-request', () => { + clearEnv(); + assertTimeoutHierarchy(); + // Pin the actual shipped numbers too: the ordering assertion alone would + // stay green if BOTH knobs were lowered together, which would silently + // shrink the allowance every long-running Odoo/Confluence report depends on. + assert.equal(resolveToolDispatchTimeoutMs(), 240_000); + assert.equal(resolveMcpCallTimeouts().maxTotalTimeoutMs, 180_000); + assert.equal(resolveMcpCallTimeouts().timeoutMs, 60_000); + }); + + it('MUTATION CHECK: the invariant counts the RETRY, not just one attempt', () => { + clearEnv(); + // The defect: `callTool` retries once, so the honest worst case for one + // dispatch was 2 × the absolute ceiling. Asserting the outer bound against + // the per-attempt number let 240 s "pass" against a 360 s reality. + assert.ok(MCP_CALL_MAX_ATTEMPTS >= 2, 'the retry this guards is still there'); + const { maxTotalTimeoutMs, worstCaseTotalMs } = resolveMcpCallTimeouts(); + // Retries SHARE the absolute budget, so the worst case is one ceiling — and + // this is the assertion that fails loudly if someone gives attempt 2 a fresh + // allowance again. + assert.equal( + worstCaseTotalMs, + maxTotalTimeoutMs, + 'the retry must not get its own maxTotalTimeout — the attempts share one budget', + ); + assert.ok( + resolveToolDispatchTimeoutMs() > worstCaseTotalMs, + `the outer dispatch deadline (${String(resolveToolDispatchTimeoutMs())}ms) must exceed the ` + + `RETRY-INCLUSIVE MCP worst case (${String(worstCaseTotalMs)}ms)`, + ); + // The number the old, per-attempt reading would have had to clear. + assert.ok( + maxTotalTimeoutMs * MCP_CALL_MAX_ATTEMPTS > resolveToolDispatchTimeoutMs(), + 'sanity: an unshared retry budget really would exceed the dispatch deadline', + ); + }); + + it('MUTATION CHECK: raising the MCP ceiling past the dispatch deadline is REFUSED at boot', () => { + clearEnv(); + process.env[MCP_TOTAL_ENV] = '300000'; + assert.throws( + () => assertTimeoutHierarchy(), + /must be strictly looser than the MCP layer's worst-case call budget/, + 'raising the MCP ceiling above the dispatch deadline was not rejected', + ); + // …and raising the outer bound with it restores coherence. + process.env[DISPATCH_ENV] = '360000'; + assertTimeoutHierarchy(); + }); + + it('MUTATION CHECK: LOWERING the dispatch deadline is refused too', () => { + clearEnv(); + // The hole the reviewer found: the invariant only ever exercised RAISING the + // inner ceiling, so lowering the OUTER bound — the far likelier operator + // action, and the one that produced the original 120 s inversion — re-created + // the inversion with green CI. `resolveToolDispatchTimeoutMs` happily accepts + // any non-negative number; the refusal has to come from the invariant check. + process.env[DISPATCH_ENV] = '90000'; + assert.equal( + resolveToolDispatchTimeoutMs(), + 90_000, + 'the resolver is pure — it reports what was configured', + ); + assert.throws( + () => assertTimeoutHierarchy(), + /OMADIA_TOOL_DISPATCH_TIMEOUT_MS=90000ms/, + 'a dispatch deadline INSIDE the MCP ceiling was not rejected', + ); + // Coherent again once the inner ceiling is lowered to match — which is the + // fix the error message tells the operator about. + process.env[MCP_TOTAL_ENV] = '60000'; + process.env[MCP_REQUEST_ENV] = '30000'; + assertTimeoutHierarchy(); + }); + + it('MUTATION CHECK: an MCP ceiling below its own per-request budget is refused', () => { + clearEnv(); + process.env[MCP_REQUEST_ENV] = '90000'; + process.env[MCP_TOTAL_ENV] = '60000'; + assert.throws( + () => assertTimeoutHierarchy(), + /must be looser than the per-request idle budget/, + ); + }); + + it('a disabled dispatch deadline (0) is treated as looser than any ceiling', () => { + clearEnv(); + process.env[DISPATCH_ENV] = '0'; + assert.equal(resolveToolDispatchTimeoutMs(), 0); + assertTimeoutHierarchy(); + }); +}); diff --git a/middleware/test/orchestrator/toolDispatchDeadline.test.ts b/middleware/test/orchestrator/toolDispatchDeadline.test.ts new file mode 100644 index 00000000..88c403aa --- /dev/null +++ b/middleware/test/orchestrator/toolDispatchDeadline.test.ts @@ -0,0 +1,370 @@ +import { describe, it, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import type { + LlmProvider, + LlmResponse, + LlmStreamEvent, +} from '@omadia/llm-provider'; +import type { ChatStreamEvent } from '@omadia/channel-sdk'; +import { + NativeToolRegistry, + Orchestrator, + turnContext, + type AskObserver, + type DomainTool, +} from '@omadia/orchestrator'; + +/** + * W0-2 — per-tool dispatch deadline. + * + * Before this, `dispatchTool` had no timeout anywhere: `domainQueryTool` awaits + * `agent.ask()` with no abort, and every tool of an iteration is dispatched into + * one `Promise.allSettled` / race loop. One hung sub-agent therefore pinned the + * whole batch for the rest of the turn. + * + * The load-bearing test here is the MUTATION CHECK: it is not enough that the + * timed-out slot returns an error — the abandoned dispatch's LATE result must + * never be written into the turn afterwards. `captureRawToolResult` is a real + * turn-state write (the routine runner reads it back as the source of truth for + * template data sections), so a late write is observable. Delete the + * `deadlineSignal?.aborted` guard in `dispatchToolDeadlined` and this test fails + * on the capture assertion, not merely on a missing error string. + */ + +const DEADLINE_MS = 150; +const LATE_VALUE = 'LATE-VALUE-from-abandoned-subagent'; +const FAST_VALUE = 'fast-tool-output'; + +const providerCapabilities = { + tools: true, + vision: true, + streaming: true, + promptCaching: true, + forcedToolChoice: true, + parallelToolCalls: true, +} as const; + +const usage = { + inputTokens: 10, + outputTokens: 2, + cacheReadTokens: 0, + cacheWriteTokens: 0, +} as const; + +function toolCallResponse( + toolUses: ReadonlyArray<{ id: string; name: string }>, +): LlmResponse { + return { + content: toolUses.map((u) => ({ + type: 'tool_call', + id: u.id, + name: u.name, + input: {}, + })), + finishReason: 'tool_calls', + providerFinishReason: 'tool_use', + model: 'test', + usage, + } as unknown as LlmResponse; +} + +function textResponse(text: string): LlmResponse { + return { + content: [{ type: 'text', text }], + finishReason: 'stop', + providerFinishReason: 'end_turn', + model: 'test', + usage, + } as unknown as LlmResponse; +} + +const sleep = (ms: number): Promise => + new Promise((r) => setTimeout(r, ms)); + +/** + * Scripted provider. `completeDelays[i]` / `streamDelays[i]` hold the i-th + * call open, which keeps the turn LIVE while the abandoned dispatch settles — + * without that window a late write could not be observed at all and the + * mutation check would be vacuous. + */ +function fakeProvider( + responses: readonly LlmResponse[], + delaysMs: readonly number[] = [], +): LlmProvider { + let idx = 0; + const next = async (): Promise => { + const i = idx; + idx += 1; + const response = responses[i]; + if (!response) { + throw new Error(`fakeProvider: no scripted response for call ${String(i + 1)}`); + } + const delay = delaysMs[i] ?? 0; + if (delay > 0) await sleep(delay); + return response; + }; + const provider = { + id: 'anthropic', + capabilities: providerCapabilities, + complete: next, + stream: (): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { + const response = await next(); + yield { type: 'final', response } as LlmStreamEvent; + }, + }), + classifyError: () => ({ retryable: false, kind: 'other' as const }), + }; + return provider as unknown as LlmProvider; +} + +const minimalSpec = (name: string): Record => ({ + name, + description: `${name} for testing`, + input_schema: { type: 'object' as const, properties: {}, required: [] }, +}); + +interface SlowToolProbe { + readonly settledLate: () => boolean; + readonly lateObserverCalls: () => number; + readonly tool: DomainTool; +} + +/** + * A sub-agent that ignores the deadline entirely — the real-world case this + * unit exists for. It resolves long after the deadline AND emits a sub-agent + * event on its way out, exercising both late-write vectors. + */ +function slowDomainTool(name: string, latencyMs: number): SlowToolProbe { + let settled = false; + let lateEmits = 0; + const tool: DomainTool = { + name, + domain: 'test.slow', + spec: minimalSpec(name) as unknown as DomainTool['spec'], + async handle(_input: unknown, observer?: AskObserver): Promise { + observer?.onIteration?.({ iteration: 1 }); + await sleep(latencyMs); + // Everything below happens AFTER the deadline fired for this slot. + lateEmits += 1; + observer?.onSubToolResult?.({ + id: 'late-sub-call', + output: LATE_VALUE, + durationMs: latencyMs, + isError: false, + }); + settled = true; + return LATE_VALUE; + }, + }; + return { + settledLate: () => settled, + lateObserverCalls: () => lateEmits, + tool, + }; +} + +function buildOrchestrator( + provider: LlmProvider, + registry: NativeToolRegistry, + domainTools: DomainTool[], +): Orchestrator { + return new Orchestrator({ + provider, + model: 'test', + maxTokens: 1024, + maxToolIterations: 5, + domainTools, + nativeToolRegistry: registry, + }); +} + +function fastToolRegistry(): NativeToolRegistry { + const registry = new NativeToolRegistry(); + registry.register('fast_tool', { + handler: async (): Promise => { + await sleep(10); + return FAST_VALUE; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + spec: minimalSpec('fast_tool') as any, + }); + return registry; +} + +const originalTimeout = process.env['OMADIA_TOOL_DISPATCH_TIMEOUT_MS']; + +afterEach(() => { + if (originalTimeout === undefined) { + delete process.env['OMADIA_TOOL_DISPATCH_TIMEOUT_MS']; + } else { + process.env['OMADIA_TOOL_DISPATCH_TIMEOUT_MS'] = originalTimeout; + } +}); + +describe('Orchestrator per-tool dispatch deadline (W0-2)', () => { + it('times out the hung tool, keeps its batch siblings, and DISCARDS the late result', async () => { + process.env['OMADIA_TOOL_DISPATCH_TIMEOUT_MS'] = String(DEADLINE_MS); + const probe = slowDomainTool('query_slow_agent', DEADLINE_MS * 4); + const orchestrator = buildOrchestrator( + // Second call is held open past the slow tool's late settle, so the turn + // is still live when the abandoned dispatch resolves. + fakeProvider( + [ + toolCallResponse([ + { id: 'use-slow', name: 'query_slow_agent' }, + { id: 'use-fast', name: 'fast_tool' }, + ]), + textResponse('done'), + ], + [0, DEADLINE_MS * 6], + ), + fastToolRegistry(), + [probe.tool], + ); + + const captured: Array<{ name: string; result: string }> = []; + const events: ChatStreamEvent[] = []; + await turnContext.run( + { + turnId: 'outer-turn', + turnDate: '2026-07-30', + captureRawToolResult: (name, result) => { + captured.push({ name, result }); + }, + }, + async () => { + // `sessionScope` switches the run-trace collector on, so the late + // sub-agent event has a real turn-state sink to corrupt: without the + // abort-guarded observer it lands in the `done` event's runTrace. + for await (const ev of orchestrator.chatStream({ + userMessage: 'go', + sessionScope: 'test::deadline', + })) { + events.push(ev); + } + }, + ); + + // The late path must actually have run, or this test proves nothing. + assert.equal( + probe.settledLate(), + true, + 'the abandoned sub-agent must have settled during the turn for this test to be meaningful', + ); + + const results = events.filter((e) => e.type === 'tool_result'); + const slow = results.find((e) => e.type === 'tool_result' && e.id === 'use-slow'); + const fast = results.find((e) => e.type === 'tool_result' && e.id === 'use-fast'); + assert.ok(slow && slow.type === 'tool_result', 'the slow slot must produce a tool_result'); + assert.ok(fast && fast.type === 'tool_result', 'the fast slot must produce a tool_result'); + + // 1. Structured error, not a hang. + assert.equal(slow.isError, true); + assert.match(slow.output, /^Error: tool `query_slow_agent` was aborted/); + assert.match(slow.output, /dispatch deadline/); + + // 2. Batch siblings are unaffected by another slot's deadline. + assert.equal(fast.isError, false); + assert.equal(fast.output, FAST_VALUE); + + // 3. MUTATION CHECK — the late result is never written into the turn. + assert.deepEqual( + captured, + [{ name: 'fast_tool', result: FAST_VALUE }], + 'only the sibling tool may reach captureRawToolResult; a late write from the aborted slot is a corruption bug', + ); + const transcript = JSON.stringify(events); + assert.equal( + transcript.includes(LATE_VALUE), + false, + 'the abandoned dispatch\'s value must not appear anywhere in the turn transcript', + ); + assert.equal( + probe.lateObserverCalls(), + 1, + 'the sub-agent still emitted its late event (so the observer guard, not the sub-agent, is what suppresses it)', + ); + // Invariant (belt-and-braces): the abort-guarded observer drops the late + // sub-event at the boundary. Downstream layers happen to ignore it too (the + // slot left the race loop, its invocation is already finished), so this + // assertion documents the boundary contract rather than being the only + // thing standing between a late event and the turn. + assert.equal( + events.some( + (e) => e.type === 'sub_tool_result' && e.id === 'late-sub-call', + ), + false, + 'a post-deadline sub-agent event must be dropped, not streamed into the turn', + ); + }); + + it('non-streaming Promise.allSettled batch: one deadline does not stop the siblings', async () => { + process.env['OMADIA_TOOL_DISPATCH_TIMEOUT_MS'] = String(DEADLINE_MS); + const probe = slowDomainTool('query_slow_agent', DEADLINE_MS * 3); + const orchestrator = buildOrchestrator( + fakeProvider([ + toolCallResponse([ + { id: 'use-slow', name: 'query_slow_agent' }, + { id: 'use-fast', name: 'fast_tool' }, + ]), + textResponse('answered'), + ]), + fastToolRegistry(), + [probe.tool], + ); + + const started = Date.now(); + const result = await orchestrator.runTurn({ userMessage: 'go' }); + const elapsed = Date.now() - started; + + assert.equal(result.answer, 'answered'); + // The turn must not wait for the hung tool (3× the deadline). + assert.ok( + elapsed < DEADLINE_MS * 3, + `turn should finish on the deadline, not on the hung tool; took ${String(elapsed)}ms`, + ); + assert.equal(probe.settledLate(), false, 'the hung tool must still be in flight'); + }); + + it('honours a 0 deadline as "disabled" (legacy behaviour)', async () => { + process.env['OMADIA_TOOL_DISPATCH_TIMEOUT_MS'] = '0'; + const probe = slowDomainTool('query_slow_agent', 60); + const orchestrator = buildOrchestrator( + fakeProvider([ + toolCallResponse([{ id: 'use-slow', name: 'query_slow_agent' }]), + textResponse('answered'), + ]), + fastToolRegistry(), + [probe.tool], + ); + + const result = await orchestrator.runTurn({ userMessage: 'go' }); + assert.equal(result.answer, 'answered'); + assert.equal( + probe.settledLate(), + true, + 'with the deadline disabled the dispatch must be awaited to completion', + ); + }); + + it('falls back to the 240s default when the env value is not a number', async () => { + process.env['OMADIA_TOOL_DISPATCH_TIMEOUT_MS'] = 'not-a-number'; + const probe = slowDomainTool('query_slow_agent', 20); + const orchestrator = buildOrchestrator( + fakeProvider([ + toolCallResponse([{ id: 'use-slow', name: 'query_slow_agent' }]), + textResponse('answered'), + ]), + fastToolRegistry(), + [probe.tool], + ); + + const result = await orchestrator.runTurn({ userMessage: 'go' }); + // A bad env value must not degrade into "no deadline" or "0ms deadline": + // the tool completes normally well inside the 240s default. + assert.equal(result.answer, 'answered'); + assert.equal(probe.settledLate(), true); + }); +}); diff --git a/middleware/test/orchestrator/toolOrderingInvariants.test.ts b/middleware/test/orchestrator/toolOrderingInvariants.test.ts new file mode 100644 index 00000000..88971f89 --- /dev/null +++ b/middleware/test/orchestrator/toolOrderingInvariants.test.ts @@ -0,0 +1,240 @@ +/** + * W0-3 — ordering invariants for the other three surfaces that feed a tool + * block: the standalone dispatch service (advertised to the loopback MCP + * server / CLI bridge), sub-agent tool lists, and the persisted MCP + * discovered-tools column. + * + * The load-bearing assertion is the negative one: sorting changes the + * advertised ARRAY ORDER only. Which spec wins a duplicate name — native tools + * take precedence over domain tools — is decided by Map insertion, and must be + * unchanged. + */ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import type { LocalSubAgentTool } from '@omadia/plugin-api'; +import type { DomainTool } from '../../packages/harness-orchestrator/src/tools/domainQueryTool.js'; +import type { ToolGrantRow } from '../../packages/harness-orchestrator/src/registry/agentGraphStore.js'; +import { NativeToolRegistry } from '../../packages/harness-orchestrator/src/nativeToolRegistry.js'; +import { ToolDispatchService } from '../../packages/harness-orchestrator/src/toolDispatchService.js'; +import { resolveSubAgentTools } from '../../packages/harness-orchestrator/src/registry/subAgentTools.js'; +import { + normalizeDiscoveredToolOrder, + sortByToolName, +} from '../../packages/harness-orchestrator/src/toolOrdering.js'; + +const schema = { + type: 'object' as const, + properties: {}, + required: [] as string[], +}; + +function domainTool(name: string, description = name): DomainTool { + return { + name, + spec: { name, description, input_schema: schema }, + domain: `domain.${name}`, + async handle() { + return `${name}-output`; + }, + } as unknown as DomainTool; +} + +describe('W0-3 — ToolDispatchService.listDispatchableToolSpecs', () => { + it('advertises name-sorted regardless of registration order', () => { + const nativeTools = new NativeToolRegistry(); + for (const name of ['n_zulu', 'n_alpha', 'n_mike']) { + nativeTools.register(name, { + handler: async () => name, + spec: { name, description: name, input_schema: schema }, + domain: 'test.x', + }); + } + + const service = new ToolDispatchService({ + nativeTools, + domainTools: [domainTool('d_yankee'), domainTool('d_bravo')], + }); + + assert.deepEqual( + service.listDispatchableToolSpecs().map((spec) => spec.name), + ['d_bravo', 'd_yankee', 'n_alpha', 'n_mike', 'n_zulu'], + ); + }); + + it('keeps native precedence on a name collision — only the order changes', () => { + const nativeTools = new NativeToolRegistry(); + // `zzz_shared` sorts last, so if sorting were driving collision resolution + // the domain spec (registered later) could plausibly win. It must not. + nativeTools.register('zzz_shared', { + handler: async () => 'native wins', + spec: { + name: 'zzz_shared', + description: 'native', + input_schema: schema, + }, + domain: 'test.x', + }); + nativeTools.register('aaa_native_only', { + handler: async () => 'ok', + spec: { + name: 'aaa_native_only', + description: 'native-only', + input_schema: schema, + }, + domain: 'test.x', + }); + + const service = new ToolDispatchService({ + nativeTools, + domainTools: [ + domainTool('zzz_shared', 'domain'), + domainTool('mmm_domain_only', 'domain-only'), + ], + }); + + const advertised = service.listDispatchableToolSpecs(); + + // Sorted… + assert.deepEqual( + advertised.map((spec) => spec.name), + ['aaa_native_only', 'mmm_domain_only', 'zzz_shared'], + ); + // …deduplicated to one entry for the colliding name… + assert.equal( + advertised.filter((spec) => spec.name === 'zzz_shared').length, + 1, + ); + // …and it is still the NATIVE spec that survives. + assert.equal( + advertised.find((spec) => spec.name === 'zzz_shared')?.description, + 'native', + ); + }); + + it('dispatch still resolves a collision to the native handler', async () => { + const nativeTools = new NativeToolRegistry(); + nativeTools.register('zzz_shared', { + handler: async () => 'native wins', + spec: { + name: 'zzz_shared', + description: 'native', + input_schema: schema, + }, + domain: 'test.x', + }); + + const service = new ToolDispatchService({ + nativeTools, + domainTools: [domainTool('zzz_shared', 'domain')], + }); + + const result = await service.dispatch('zzz_shared', {}); + assert.equal(result.content, 'native wins'); + }); +}); + +describe('W0-3 — resolveSubAgentTools', () => { + const grant = (toolRef: string, index: number): ToolGrantRow => ({ + id: `grant-${String(index)}`, + agentId: null, + subAgentId: 'sub-1', + toolKind: 'native', + toolRef, + mcpServerId: null, + config: {}, + // Grants arrive in `created_at` order — deliberately the inverse of + // alphabetical here, so an unsorted implementation is visible. + createdAt: new Date(2026, 0, 100 - index), + }); + + const nativeTool = (toolRef: string): LocalSubAgentTool => + ({ + spec: { name: toolRef, description: toolRef, input_schema: schema }, + async handle() { + return `${toolRef}-output`; + }, + }) as unknown as LocalSubAgentTool; + + it('returns the granted tools name-sorted', () => { + const grants = ['s_zulu', 's_alpha', 's_papa', 's_bravo'].map(grant); + + const resolved = resolveSubAgentTools(grants, { nativeTool }); + + assert.deepEqual( + resolved.map((tool) => tool.spec.name), + ['s_alpha', 's_bravo', 's_papa', 's_zulu'], + ); + }); + + it('drops unresolvable grants without disturbing the order', () => { + const grants = ['s_zulu', 's_missing', 's_alpha'].map(grant); + + const resolved = resolveSubAgentTools(grants, { + nativeTool: (ref) => (ref === 's_missing' ? undefined : nativeTool(ref)), + }); + + assert.deepEqual( + resolved.map((tool) => tool.spec.name), + ['s_alpha', 's_zulu'], + ); + }); +}); + +describe('W0-3 — normalizeDiscoveredToolOrder', () => { + it('sorts discovered tools by name so rediscovery does not churn the JSONB', () => { + const fromServer = [ + { name: 'search', description: 'b' }, + { name: 'create', description: 'a' }, + { name: 'update', description: 'c' }, + ]; + + // Same set, different wire order — must normalize to identical bytes. + const shuffled = [fromServer[2], fromServer[0], fromServer[1]]; + + assert.equal( + JSON.stringify(normalizeDiscoveredToolOrder(fromServer)), + JSON.stringify(normalizeDiscoveredToolOrder(shuffled)), + ); + assert.deepEqual( + normalizeDiscoveredToolOrder(fromServer).map( + (tool) => (tool as { name: string }).name, + ), + ['create', 'search', 'update'], + ); + }); + + it('degrades rather than throwing on entries without a usable name', () => { + const malformed = [ + { name: 'beta' }, + null, + { name: 42 }, + 'not-an-object', + { name: 'alpha' }, + ]; + + const normalized = normalizeDiscoveredToolOrder(malformed); + + // Named entries sort first, unnamed keep their relative order. + assert.equal(normalized.length, malformed.length); + assert.deepEqual(normalized.slice(0, 2), [ + { name: 'alpha' }, + { name: 'beta' }, + ]); + assert.deepEqual(normalized.slice(2), [null, { name: 42 }, 'not-an-object']); + }); + + it('sortByToolName does not mutate its input', () => { + const input = [{ name: 'b' }, { name: 'a' }]; + const sorted = sortByToolName(input); + + assert.deepEqual( + input.map((item) => item.name), + ['b', 'a'], + ); + assert.deepEqual( + sorted.map((item) => item.name), + ['a', 'b'], + ); + }); +}); diff --git a/middleware/test/orchestrator/turnContextPropagation.test.ts b/middleware/test/orchestrator/turnContextPropagation.test.ts new file mode 100644 index 00000000..5dc310e2 --- /dev/null +++ b/middleware/test/orchestrator/turnContextPropagation.test.ts @@ -0,0 +1,500 @@ +/** + * W3-A — `turnContext` must reach tool handlers on BOTH orchestrator entry + * points. + * + * The streaming entry point used to establish the turn scope with + * `AsyncLocalStorage.enterWith` (`turnContext.enter`). `enterWith` binds the + * store to the async resource that is executing at that instant; an async + * generator, however, is resumed in the async context of whoever called + * `.next()`. So the moment `chatStream` yielded its first event the store was + * gone, and every tool handler further down ran with `turnContext.current()` + * either `undefined` or — worse — bound to whatever OUTER scope the consumer + * happened to be iterating from. + * + * That silently broke the MCP audit trail on every streaming turn (which is + * every web-ui and every channel turn): `callerKind` degraded to + * `unattributed`, `turnId` to `null`, `callerAgent` to `null`, and the + * per-user OAuth identity (`mcpUserKey`) was unreachable, so `resolveIdentity` + * recorded `unresolved`. + * + * These tests therefore assert the OBSERVABLE audit row, not just the context + * object. Tests labelled MUTATION CHECK were verified by breaking the + * invariant, rebuilding, and confirming the assertion turns red. + */ +import { after, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { + createServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { Server as McpSdkServer } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import type { + LlmProvider, + LlmRequest, + LlmResponse, + LlmStreamEvent, +} from '@omadia/llm-provider'; +import { + McpManager, + NativeToolRegistry, + Orchestrator, + steeringBus, + turnContext, + type McpCallLogEntry, + type McpServerConfig, + type TurnContextValue, +} from '@omadia/orchestrator'; + +import { UNRESOLVED_IDENTITY, auditIdentity } from '../../src/services/mcpDelegation.js'; + +// ── fake MCP server ───────────────────────────────────────────────────────── + +function buildMcpServerInstance(): McpSdkServer { + const mcp = new McpSdkServer( + { name: 'fake-crm', version: '0.0.0' }, + { capabilities: { tools: {} } }, + ); + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [{ name: 'ping', inputSchema: { type: 'object' as const } }], + })); + mcp.setRequestHandler(CallToolRequestSchema, async () => ({ + content: [{ type: 'text' as const, text: 'pong' }], + })); + return mcp; +} + +async function startFakeMcpServer(): Promise<{ url: string; close(): Promise }> { + const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + const mcp = buildMcpServerInstance(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on('close', () => { + void transport.close().catch(() => {}); + void mcp.close().catch(() => {}); + }); + await mcp.connect(transport); + if (req.method === 'POST') { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8'); + await transport.handleRequest(req, res, raw.length > 0 ? JSON.parse(raw) : undefined); + return; + } + await transport.handleRequest(req, res); + }; + const http: HttpServer = createServer((req, res) => { + void handle(req, res).catch(() => { + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + }); + const sockets = new Set(); + http.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => http.listen(0, '127.0.0.1', () => resolve())); + const { port } = http.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/mcp`, + async close() { + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await new Promise((resolve) => http.close(() => resolve())); + }, + }; +} + +const fake = await startFakeMcpServer(); +after(() => fake.close()); + +const CFG: McpServerConfig = { + id: '00000000-0000-4000-8000-0000000003a0', + name: 'Kunden-CRM', + transport: 'http', + endpoint: fake.url, +}; + +// ── scripted provider ─────────────────────────────────────────────────────── + +const providerCapabilities = { + tools: true, + vision: true, + streaming: true, + promptCaching: true, + forcedToolChoice: true, + parallelToolCalls: true, +} as const; + +function fakeProvider(streams: LlmStreamEvent[][]): LlmProvider { + let idx = 0; + const take = (): LlmStreamEvent[] => { + if (idx >= streams.length) { + throw new Error(`no scripted stream for provider call ${String(idx + 1)}`); + } + const events = streams[idx]!; + idx += 1; + return events; + }; + return { + id: 'anthropic', + capabilities: providerCapabilities, + complete: async (): Promise => { + const events = take(); + const final = events.at(-1) as { type: string; response: LlmResponse }; + return final.response; + }, + stream: (): AsyncIterable => { + const events = take(); + return { + async *[Symbol.asyncIterator]() { + for (const ev of events) yield ev; + }, + }; + }, + classifyError: () => ({ retryable: false, kind: 'other' as const }), + } as unknown as LlmProvider; +} + +function toolCallStream( + calls: Array<{ id: string; name: string; input: unknown }>, +): LlmStreamEvent[] { + return [ + { + type: 'final', + response: { + content: calls.map((c) => ({ + type: 'tool_call', + id: c.id, + name: c.name, + input: c.input, + })), + finishReason: 'tool_calls', + providerFinishReason: 'tool_use', + model: 'test', + usage: { inputTokens: 50, outputTokens: 4, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + } as LlmStreamEvent, + ]; +} + +function textStream(text: string): LlmStreamEvent[] { + return [ + { type: 'text_delta', text }, + { + type: 'final', + response: { + content: [{ type: 'text', text }], + finishReason: 'stop', + providerFinishReason: 'end_turn', + model: 'test', + usage: { inputTokens: 100, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + } as LlmStreamEvent, + ]; +} + +const PROBE_TOOL = 'probe_turn_context'; +const AGENT_SLUG = 'probe-agent'; + +/** A snapshot of what a tool handler saw, so assertions read the HANDLER's + * view of the turn rather than the test's. */ +interface Seen { + readonly defined: boolean; + readonly turnId: string | undefined; + readonly agentSlug: string | undefined; + readonly userId: string | undefined; + readonly sessionScope: string | undefined; + readonly mcpUserKey: string | undefined; +} + +function snapshot(ctx: TurnContextValue | undefined): Seen { + return { + defined: ctx !== undefined, + turnId: ctx?.turnId, + agentSlug: ctx?.agentSlug, + userId: ctx?.userId, + sessionScope: ctx?.sessionScope, + mcpUserKey: ctx?.mcpUserKey, + }; +} + +interface Harness { + readonly orchestrator: Orchestrator; + /** One entry per `probe_turn_context` dispatch, in dispatch order. */ + readonly seen: Seen[]; + /** Every audit row the McpManager emitted. */ + readonly audit: McpCallLogEntry[]; +} + +/** + * @param callMcp when true the probe handler ALSO makes a real MCP call, so + * the audit row is produced from inside the tool-dispatch + * call tree — exactly where production makes it. + */ +function harness( + streams: LlmStreamEvent[][], + opts?: { readonly callMcp?: boolean }, +): Harness { + const seen: Seen[] = []; + const audit: McpCallLogEntry[] = []; + const manager = new McpManager({ + onToolCall: (entry) => audit.push(entry), + auth: { + // Mirrors the production wiring in `src/index.ts`: the identity is + // resolved from the TURN CONTEXT, per server delegation mode. + getToken: async () => null, + onAuthFailure: async () => null, + resolveIdentity: async () => + auditIdentity({ delegation: 'per_user' }, turnContext.current()?.mcpUserKey), + }, + }); + const registry = new NativeToolRegistry(); + registry.register(PROBE_TOOL, { + handler: async () => { + seen.push(snapshot(turnContext.current())); + if (opts?.callMcp) return manager.callTool(CFG, 'ping', {}); + return 'ok'; + }, + spec: { + name: PROBE_TOOL, + description: 'Records the ambient turn context.', + input_schema: { type: 'object' as const, properties: {}, required: [] }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + agentId: 'probe', + }); + const orchestrator = new Orchestrator({ + provider: fakeProvider(streams), + model: 'test', + maxTokens: 1024, + maxToolIterations: 5, + domainTools: [], + nativeToolRegistry: registry, + agentId: AGENT_SLUG, + }); + return { orchestrator, seen, audit }; +} + +const oneToolTurn = (): LlmStreamEvent[][] => [ + toolCallStream([{ id: 'tu-1', name: PROBE_TOOL, input: {} }]), + textStream('fertig'), +]; + +async function drain(orchestrator: Orchestrator, userId = 'u1'): Promise { + for await (const _ of orchestrator.chatStream({ + userMessage: 'los', + sessionScope: 'sess-w3a', + userId, + })) { + // drain + } +} + +// ── 1. the context reaches a tool handler on BOTH paths ───────────────────── + +describe('turnContext reaches tool handlers (W3-A)', () => { + it('MUTATION CHECK: buffered path (runTurn) — handler sees the full turn context', async () => { + const h = harness(oneToolTurn()); + await h.orchestrator.runTurn({ + userMessage: 'los', + sessionScope: 'sess-w3a', + userId: 'u1', + }); + assert.equal(h.seen.length, 1, 'the probe tool never ran'); + const s = h.seen[0]!; + assert.equal(s.defined, true, 'turnContext.current() was undefined in the tool handler'); + assert.ok(s.turnId && s.turnId.length > 0, 'no turnId in the tool handler'); + assert.equal(s.agentSlug, AGENT_SLUG); + assert.equal(s.userId, 'u1'); + assert.equal(s.sessionScope, 'sess-w3a'); + }); + + it('MUTATION CHECK: streaming path (chatStream) — handler sees the full turn context', async () => { + // This is THE regression. `turnContext.enter` (enterWith) does not survive + // the generator's first `yield`, so before the fix `defined` was false. + const h = harness(oneToolTurn()); + await drain(h.orchestrator); + assert.equal(h.seen.length, 1, 'the probe tool never ran'); + const s = h.seen[0]!; + assert.equal(s.defined, true, 'turnContext.current() was undefined in the tool handler'); + assert.ok(s.turnId && s.turnId.length > 0, 'no turnId in the tool handler'); + assert.equal(s.agentSlug, AGENT_SLUG); + assert.equal(s.userId, 'u1'); + assert.equal(s.sessionScope, 'sess-w3a'); + }); + + it('MUTATION CHECK: streaming turns do not leak each other`s context', async () => { + // Two turns on the SAME orchestrator, interleaved at the generator level: + // both streams are advanced alternately, so a single shared store (or an + // `enterWith` that bleeds across async resources) shows up as a duplicate + // turnId here. + const a = harness(oneToolTurn()); + const b = harness(oneToolTurn()); + const genA = a.orchestrator.chatStream({ userMessage: 'a', sessionScope: 's-a', userId: 'ua' }); + const genB = b.orchestrator.chatStream({ userMessage: 'b', sessionScope: 's-b', userId: 'ub' }); + let doneA = false; + let doneB = false; + while (!doneA || !doneB) { + if (!doneA) doneA = (await genA.next()).done === true; + if (!doneB) doneB = (await genB.next()).done === true; + } + assert.equal(a.seen[0]?.userId, 'ua'); + assert.equal(b.seen[0]?.userId, 'ub'); + assert.notEqual(a.seen[0]?.turnId, b.seen[0]?.turnId, 'both turns shared one turnId'); + assert.equal(a.seen[0]?.sessionScope, 's-a'); + assert.equal(b.seen[0]?.sessionScope, 's-b'); + }); + + it('MUTATION CHECK: a write onto the LIVE store survives to a later tool iteration', async () => { + // `activePersonaSkillId` and `mcpInputReplayNote` are documented to be + // MUTATED onto the live store inside the turn scope. A propagation fix that + // re-created the context object per generator step would silently drop + // those writes, so pin the behaviour: iteration 1 writes, iteration 2 reads. + const observed: Array = []; + const registry = new NativeToolRegistry(); + registry.register(PROBE_TOOL, { + handler: async () => { + const ctx = turnContext.current(); + observed.push(ctx?.activePersonaSkillId); + if (ctx) ctx.activePersonaSkillId = 'persona-x'; + return 'ok'; + }, + spec: { + name: PROBE_TOOL, + description: 'Mutates the live turn context.', + input_schema: { type: 'object' as const, properties: {}, required: [] }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + agentId: 'probe', + }); + const orchestrator = new Orchestrator({ + provider: fakeProvider([ + toolCallStream([{ id: 'tu-1', name: PROBE_TOOL, input: {} }]), + toolCallStream([{ id: 'tu-2', name: PROBE_TOOL, input: {} }]), + textStream('fertig'), + ]), + model: 'test', + maxTokens: 1024, + maxToolIterations: 5, + domainTools: [], + nativeToolRegistry: registry, + agentId: AGENT_SLUG, + }); + for await (const _ of orchestrator.chatStream({ userMessage: 'los', sessionScope: 's' })) { + // drain + } + assert.deepEqual(observed, [undefined, 'persona-x'], 'live-store mutation was lost'); + }); + + it('MUTATION CHECK: an abandoned stream still tears the turn down INSIDE the scope', async () => { + // A web-ui client disconnecting mid-turn `break`s out of the `for await`. + // The body's own `finally` (steering-bus teardown, privacy finalisation) + // must still run, and must still see the turn context — outside it those + // handlers would operate on the wrong (or no) turn. + const registry = new NativeToolRegistry(); + registry.register(PROBE_TOOL, { + handler: async () => 'ok', + spec: { + name: PROBE_TOOL, + description: 'noop', + input_schema: { type: 'object' as const, properties: {}, required: [] }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + agentId: 'probe', + }); + const orchestrator = new Orchestrator({ + provider: fakeProvider(oneToolTurn()), + model: 'test', + maxTokens: 1024, + maxToolIterations: 5, + domainTools: [], + nativeToolRegistry: registry, + agentId: AGENT_SLUG, + }); + const seenByConsumer: Array = []; + for await (const event of orchestrator.chatStream({ + userMessage: 'los', + sessionScope: 's-abandon', + })) { + seenByConsumer.push(turnContext.currentTurnId()); + if (event.type === 'tool_result') break; // client disconnected + } + // 1. The body's `finally` ran: the steering bus released this scope. An + // abandoned turn that stays "live" would accept steers forever. + assert.equal( + steeringBus.enqueue('s-abandon', 'nachtrag').live, + false, + 'the turn was never torn down — steeringBus still reports it live', + ); + // 2. The CONSUMER must never inherit the turn scope. That leak is exactly + // what `enterWith` produced, and it is why an audit row could be + // attributed to the consumer's ambient turn instead of this one. + assert.deepEqual( + [...new Set(seenByConsumer)], + [undefined], + 'the turn scope leaked into the consumer', + ); + }); +}); + +// ── 2. the DOWNSTREAM consequence: the MCP audit row ──────────────────────── + +describe('MCP audit attribution on a streaming turn (W3-A)', () => { + it('MUTATION CHECK: the audit row names the agent and the turn, not `unattributed`', async () => { + const h = harness(oneToolTurn(), { callMcp: true }); + await drain(h.orchestrator); + assert.equal(h.audit.length, 1, 'no mcp_call_log row was emitted'); + const row = h.audit[0]!; + // Before the fix: 'unattributed' / null / null — on EVERY streaming turn. + assert.equal(row.callerKind, 'agent'); + assert.ok(row.turnId !== null && row.turnId.length > 0, 'audit row has no turnId'); + assert.equal(row.callerAgent, AGENT_SLUG); + assert.equal(row.turnId, h.seen[0]?.turnId, 'audit row and handler disagree on the turn'); + assert.equal(row.outcome, 'ok'); + }); + + it('MUTATION CHECK: a per-user identity established by an outer scope reaches the audit row', async () => { + // A channel adapter (Teams) establishes the caller identity in an OUTER ALS + // scope and then consumes `chatStream` inside it — the same shape + // `runWithChatParticipants` uses. Both the propagation fix AND the + // `mcpUserKey` carry-over are required for this to be anything but + // `unresolved`. + const h = harness(oneToolTurn(), { callMcp: true }); + await turnContext.run( + { + turnId: 'outer-adapter-turn', + turnDate: '2026-07-30', + mcpUserKey: 'alice@example.com', + }, + async () => { + await drain(h.orchestrator); + }, + ); + assert.equal(h.audit.length, 1, 'no mcp_call_log row was emitted'); + const row = h.audit[0]!; + assert.notEqual( + row.actingIdentity, + UNRESOLVED_IDENTITY, + 'per_user delegation recorded `unresolved` despite a resolvable caller', + ); + assert.equal(row.actingIdentity, 'alice@example.com'); + // The row must be attributed to THIS turn, never to the adapter's outer + // placeholder scope — which is exactly what a leaked `enterWith` produced. + assert.notEqual(row.turnId, 'outer-adapter-turn'); + assert.equal(row.callerAgent, AGENT_SLUG); + assert.equal(h.seen[0]?.mcpUserKey, 'alice@example.com'); + }); +}); diff --git a/middleware/test/orchestrator/turnContextTeardown.test.ts b/middleware/test/orchestrator/turnContextTeardown.test.ts new file mode 100644 index 00000000..1d46af3e --- /dev/null +++ b/middleware/test/orchestrator/turnContextTeardown.test.ts @@ -0,0 +1,143 @@ +/** + * W4 — a failing turn teardown must not become the turn's exit reason. + * + * `turnContext.runGenerator` drives the inner generator's own `finally` blocks + * (steering-bus teardown, privacy finalisation) inside the turn scope when a + * consumer stops early. That drive — `await inner.return(undefined)` — sat bare + * inside the wrapper's OWN `finally`, and an abrupt completion in a `finally` + * REPLACES the pending completion of the whole generator. So a throwing privacy + * finaliser overwrote the client abort that actually ended the turn: the caller + * was handed a secondary teardown failure and the real reason — the one worth + * debugging — was gone. + * + * The teardown failure is not swallowed either: it is reported through + * `onTurnTeardownError`, which is what these tests assert against rather than + * scraping console output. + */ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { onTurnTeardownError, turnContext } from '@omadia/orchestrator'; + +const TURN = { turnId: 'turn-teardown', turnDate: '2026-07-30' }; + +let restore: (() => void) | undefined; +afterEach(() => { + restore?.(); + restore = undefined; +}); + +/** Capture what the wrapper reports instead of throwing. */ +function captureTeardownErrors(): { seen: { turnId: string; err: unknown }[] } { + const seen: { turnId: string; err: unknown }[] = []; + restore = onTurnTeardownError((turnId, err) => seen.push({ turnId, err })); + return { seen }; +} + +/** + * An inner generator whose own `finally` throws — the privacy finaliser that + * fails while the turn is already unwinding. + */ +function generatorWithFailingTeardown( + observed: { turnIdAtTeardown?: string }, +): () => AsyncGenerator { + return async function* inner(): AsyncGenerator { + try { + yield 'chunk-1'; + yield 'chunk-2'; + } finally { + observed.turnIdAtTeardown = turnContext.currentTurnId(); + throw new Error('privacy finalisation exploded'); + } + }; +} + +describe('turnContext.runGenerator — teardown never replaces the exit reason (W4)', () => { + it('MUTATION CHECK: a client abort survives a throwing finaliser', async () => { + // The abort arrives as `gen.throw(...)` — the consumer telling the stream it + // is over. Under the bug the teardown error replaced it and the caller was + // told privacy finalisation failed, with no sign the client had disconnected. + const { seen } = captureTeardownErrors(); + const observed: { turnIdAtTeardown?: string } = {}; + const gen = turnContext.runGenerator(TURN, generatorWithFailingTeardown(observed)); + + assert.equal((await gen.next()).value, 'chunk-1'); + + const abort = new Error('client aborted the stream'); + await assert.rejects( + () => gen.throw(abort), + (err: unknown) => { + assert.equal(err, abort, 'the ORIGINAL abort must be what the caller sees'); + return true; + }, + ); + + // …and the teardown failure is surfaced, not lost. + assert.equal(seen.length, 1, 'the teardown failure must be reported'); + assert.equal(seen[0]?.turnId, TURN.turnId); + assert.match(String((seen[0]?.err as Error).message), /privacy finalisation exploded/); + }); + + it('MUTATION CHECK: a consumer breaking out is not turned into a failure', async () => { + // The commonest shape: an SSE consumer stops reading (`break`), which calls + // `gen.return()`. A throwing finaliser used to make that clean stop reject. + const { seen } = captureTeardownErrors(); + const observed: { turnIdAtTeardown?: string } = {}; + const gen = turnContext.runGenerator(TURN, generatorWithFailingTeardown(observed)); + + const received: string[] = []; + for await (const chunk of gen) { + received.push(chunk); + break; + } + + assert.deepEqual(received, ['chunk-1']); + assert.equal(seen.length, 1, 'the teardown failure must still be reported'); + assert.match(String((seen[0]?.err as Error).message), /privacy finalisation exploded/); + }); + + it('teardown still runs INSIDE the turn scope — the reason this helper exists', async () => { + // Regression guard on the original property: catching the teardown error must + // not have moved the drive outside `storage.run`, or the finaliser would run + // context-less and write to the wrong turn (or to none). + captureTeardownErrors(); + const observed: { turnIdAtTeardown?: string } = {}; + const gen = turnContext.runGenerator(TURN, generatorWithFailingTeardown(observed)); + await gen.next(); + await gen.return(undefined); + assert.equal(observed.turnIdAtTeardown, TURN.turnId); + }); + + it('a clean, fully-drained generator neither tears down nor reports anything', async () => { + const { seen } = captureTeardownErrors(); + let finallyRuns = 0; + const gen = turnContext.runGenerator(TURN, async function* (): AsyncGenerator { + try { + yield 'only'; + } finally { + finallyRuns += 1; + } + }); + + const all: string[] = []; + for await (const chunk of gen) all.push(chunk); + + assert.deepEqual(all, ['only']); + assert.equal(finallyRuns, 1, 'the generator completed on its own'); + assert.deepEqual(seen, [], 'a clean turn reports no teardown failure'); + }); + + it('a reporter that itself throws cannot become the exit reason either', async () => { + restore = onTurnTeardownError(() => { + throw new Error('the error reporter is down too'); + }); + const observed: { turnIdAtTeardown?: string } = {}; + const gen = turnContext.runGenerator(TURN, generatorWithFailingTeardown(observed)); + await gen.next(); + const abort = new Error('client aborted the stream'); + await assert.rejects( + () => gen.throw(abort), + (err: unknown) => err === abort, + ); + }); +}); diff --git a/middleware/test/postgresMemoryStorePathValidation.test.ts b/middleware/test/postgresMemoryStorePathValidation.test.ts new file mode 100644 index 00000000..e7c86eb1 --- /dev/null +++ b/middleware/test/postgresMemoryStorePathValidation.test.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { MemoryInvalidPathError } from '@omadia/memory'; +import { PostgresMemoryStore } from '@omadia/memory-postgres'; +import type { Pool } from 'pg'; + +/** + * W6-1 follow-through — coverage for `PostgresMemoryStore`'s NUL-byte guard. + * + * The guard existed but was provably untested: disabling it entirely left the + * whole middleware suite green. Its only coverage was + * `memoryStoreConformance.pg.test.ts`, which skips without a Postgres and + * asserts nothing about NUL anyway — and CI has no Postgres service on the + * middleware job, so that suite never runs there at all. + * + * These tests need no Postgres by construction. `normalize()` runs before any + * query, so a pool that THROWS when queried is the assertion: if validation + * ever moved after the first query, the fake would fire and the test would fail + * with the wrong error. + */ + +/** A pool whose only behaviour is to fail loudly if anyone reaches it. */ +function poolThatMustNotBeQueried(): Pool { + return { + query() { + throw new Error('the store queried the database before validating the path'); + }, + connect() { + throw new Error('the store took a client before validating the path'); + }, + } as unknown as Pool; +} + +describe('PostgresMemoryStore path validation', () => { + it('rejects a path containing a NUL byte, before touching the pool', async () => { + const store = new PostgresMemoryStore(poolThatMustNotBeQueried()); + + await assert.rejects( + () => store.fileExists('/memories/core/no\0tes.md'), + (err: unknown) => { + assert.ok( + err instanceof MemoryInvalidPathError, + `expected MemoryInvalidPathError, got ${String(err)}`, + ); + return true; + }, + ); + }); + + it('names the NUL byte in the error, rather than reporting a space', async () => { + // The message read 'Path contains a space.' until W6-1 — almost certainly + // because the raw 0x00 in the source was invisible to whoever wrote it. + // An error that misnames the input it rejected sends the reader looking for + // a bug that is not there. + const store = new PostgresMemoryStore(poolThatMustNotBeQueried()); + + await assert.rejects( + () => store.fileExists('/memories/core/no\0tes.md'), + (err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + assert.match(message, /NUL/i, `error should name the NUL byte, got: ${message}`); + assert.doesNotMatch( + message, + /space/i, + `error must not blame a space for a NUL byte, got: ${message}`, + ); + return true; + }, + ); + }); + + it('applies the guard on every entry point, not just one', async () => { + const store = new PostgresMemoryStore(poolThatMustNotBeQueried()); + const bad = '/memories/core/no\0tes.md'; + + // ALL EIGHT path-taking methods, not a sample. An earlier version of this + // test covered four and still called itself "every entry point"; a refactor + // that added an early return to `delete` — bypassing normalize on the most + // destructive method — would have passed it. + await assert.rejects(() => store.list(bad), MemoryInvalidPathError); + await assert.rejects(() => store.fileExists(bad), MemoryInvalidPathError); + await assert.rejects(() => store.directoryExists(bad), MemoryInvalidPathError); + await assert.rejects(() => store.readFile(bad), MemoryInvalidPathError); + await assert.rejects(() => store.createFile(bad, 'x'), MemoryInvalidPathError); + await assert.rejects(() => store.writeFile(bad, 'x'), MemoryInvalidPathError); + await assert.rejects(() => store.delete(bad), MemoryInvalidPathError); + await assert.rejects(() => store.rename(bad, '/memories/core/ok.md'), MemoryInvalidPathError); + }); + + it("normalises rename's DESTINATION, not only its source", async () => { + // `rename` is the one method taking two paths, and the second was entirely + // unpinned: a refactor keeping `normalize(from)` and dropping `normalize(to)` + // passed the whole suite. Defence in depth rather than an injection hole — + // pg parameterisation rejects a NUL in a bind parameter at the driver — but + // the guard should not depend on the driver to hold. + const store = new PostgresMemoryStore(poolThatMustNotBeQueried()); + + await assert.rejects( + () => store.rename('/memories/core/from.md', '/memories/core/no\0tes.md'), + MemoryInvalidPathError, + ); + }); + + it('accepts ordinary paths far enough to reach the pool', async () => { + // The negative control, and it has to be wider than one tidy path. + // + // The bug this file exists for was a NUL branch reporting 'Path contains a + // space.' Someone reading that message in a stale checkout "fixes" it the + // other way round and adds a real space rejection. With a single + // space-free control path, every test above stays green while every memory + // file whose name contains a space breaks at runtime — the suite built to + // protect this line would say nothing. + const store = new PostgresMemoryStore(poolThatMustNotBeQueried()); + + for (const clean of [ + '/memories/core/notes.md', + '/memories/core/my notes.md', // a space is legal, and must stay legal + '/memories/core/notes.v2.md', // a dot that is not a traversal + '/memories/core/Ünïcödé.md', + ]) { + await assert.rejects( + () => store.fileExists(clean), + (err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + assert.match( + message, + /before validating the path/, + `${clean} must survive validation and reach the pool, got: ${message}`, + ); + return true; + }, + ); + } + }); +}); diff --git a/middleware/test/profileStorage.test.ts b/middleware/test/profileStorage.test.ts index fd8f8848..bc89c8e0 100644 Binary files a/middleware/test/profileStorage.test.ts and b/middleware/test/profileStorage.test.ts differ diff --git a/middleware/test/publicMcp/harness.ts b/middleware/test/publicMcp/harness.ts new file mode 100644 index 00000000..0955a0b7 --- /dev/null +++ b/middleware/test/publicMcp/harness.ts @@ -0,0 +1,462 @@ +/** + * W2-3 (issue #542) — shared harness for the public MCP endpoint's e2e tests. + * + * ─── Why this reproduces the real chain instead of a bare `express()` app ──── + * + * The doc comment at the top of `src/auth/publicPaths.ts` records the bug this + * exists to avoid: epic #470's runner router was mounted without a session + * guard, and its e2e test built its OWN bare `express()` app to prove it — so + * the test passed while the route 401'd in production behind the blanket `/api` + * guard. A test app that omits the guard proves nothing about a route whose + * reachability depends on it. + * + * So this harness assembles, in order, exactly what `src/index.ts` assembles: + * + * 1. `express.json({ limit: '10mb' })` — the same global parser, which is why + * the 8 MB cap cannot be an `express.json` limit (see `bodyCapMiddleware`). + * 2. `app.use('/api', requireAuth, )` — the OB-106 line. It runs for + * EVERY `/api/*` request whichever router answers, which is what makes the + * `publicPaths` entry load-bearing. + * 3. `mountPublicMcp(app, requireAuth, …)` — the SAME function index.ts calls, + * not a hand-rolled equivalent. + * 4. `createRequireAuth({ publicPaths: publicPaths({ … }) })` — the SAME + * shared allowlist production runs. + * + * `withoutPublicPathEntry` drives the negative half: strip the entry and the + * route must go DARK (401), never open. + */ + +import type { AddressInfo } from 'node:net'; +import { createServer, type Server } from 'node:http'; + +import express, { type Express } from 'express'; + +import type { ApiKeyRecord, ApiKeyStore, ApiKeyScope } from '@omadia/api-key-auth'; +import { createRateLimiter, sha256Hex } from '@omadia/api-key-auth'; +import { + createPrivacyTurnHandle, + NativeToolRegistry, + ToolDispatchService, +} from '@omadia/orchestrator'; +import type { + DispatchableToolSpec, + DomainTool, + PrivacyTurnHandle, + ToolDispatchResult, +} from '@omadia/orchestrator'; +import { isWriteCapableTool } from '@omadia/plugin-api'; +import type { PrivacyGuardService, WriteCapability } from '@omadia/plugin-api'; + +import { publicPaths, STATIC_PUBLIC_PATHS } from '../../src/auth/publicPaths.js'; +import { createRequireAuth } from '../../src/auth/requireAuth.js'; +import { EmailWhitelist } from '../../src/auth/whitelist.js'; +import { createInMemoryPublicMcpKeyBindingStore } from '../../src/mcp/publicMcpKeyBindings.js'; +import { PUBLIC_MCP_PATH } from '../../src/mcp/publicMcpPath.js'; +import { mountPublicMcp } from '../../src/mcp/wirePublicMcp.js'; +import type { + PublicMcpAuditEntry, + PublicMcpDispatcher, +} from '../../src/mcp/publicMcpServer.js'; + +export const MCP_ACCEPT = 'application/json, text/event-stream'; + +/** Reads either a plain JSON body or the SSE framing the transport may use. */ +export function parseMcpJson(text: string): Record { + const trimmed = text.trim(); + if (!trimmed.startsWith('event:') && !trimmed.startsWith('data:')) { + return JSON.parse(trimmed) as Record; + } + const data = trimmed + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice('data:'.length).trim()) + .filter(Boolean); + return JSON.parse(data.join('\n')) as Record; +} + +/** True when the sandbox refuses loopback listeners, so callers can self-skip. */ +export function isSandboxListenDenied(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EPERM'; +} + +export interface FakeKey { + readonly token: string; + readonly id: string; + readonly scopes: readonly ApiKeyScope[]; + readonly rateLimitPerMinute?: number; +} + +/** + * An `ApiKeyStore` over an in-memory key list. + * + * Only `verify` is reachable from the endpoint; the mutators throw so a test + * that accidentally exercises a write path fails loudly rather than silently + * "succeeding". Verification hashes the token the same way `apiKeyToken.ts` + * does, so a token/record mismatch fails here the way it would in production. + */ +export function fakeApiKeyStore(keys: readonly FakeKey[]): ApiKeyStore { + const records: ApiKeyRecord[] = keys.map((k) => ({ + id: k.id, + hash: sha256Hex(k.token), + rateLimitPerMinute: k.rateLimitPerMinute ?? 60, + scopes: k.scopes, + createdAt: Date.now(), + })); + return { + create: () => Promise.reject(new Error('not used')), + list: () => Promise.reject(new Error('not used')), + revoke: () => Promise.reject(new Error('not used')), + verify: (token) => + Promise.resolve(records.find((r) => r.hash === sha256Hex(String(token)))), + }; +} + +export interface FakeTool { + readonly name: string; + /** Called on dispatch. Default returns a deterministic marker. */ + readonly handle?: (input: unknown) => Promise; + /** + * Declared write capabilities, i.e. what `isWriteCapableTool` reads. + * Omitted ⇒ the tool declares nothing and the dispatch layer treats it as a + * READ — which is exactly the "unannotated write tool" case the endpoint must + * still catch via the operator's `write_tools` list. + */ + readonly writeCapabilities?: readonly WriteCapability[]; +} + +/** A minimal `update` capability, enough for `isWriteCapableTool` to fire. */ +export const DECLARED_WRITE: readonly WriteCapability[] = [ + { dataClass: 'test.record', operation: 'update' }, +]; + +/** + * A dispatcher for ONE agent, advertising exactly `tools`. + * + * A FAKE: it runs no privacy pipeline, so it exercises the endpoint's + * authorization gates without the Privacy Shield in the way. `withPrivacy` is + * therefore a pass-through that only RECORDS whether a handle was installed — + * enough to assert the endpoint supplies one. Real masking behaviour is proven + * against `realDispatcher` below, which runs the actual `ToolDispatchService`. + */ +export function fakeDispatcher( + tools: readonly FakeTool[], + seen?: { name: string; input: unknown }[], + privacyInstalled?: { value: boolean }, +): PublicMcpDispatcher { + const specs: DispatchableToolSpec[] = tools.map((t) => ({ + name: t.name, + description: `desc:${t.name}`, + input_schema: { type: 'object' as const, properties: {} }, + })); + return { + listDispatchableToolSpecs: () => specs, + isWriteCapable: (name) => + isWriteCapableTool(tools.find((t) => t.name === name)?.writeCapabilities), + async dispatch(name, input) { + seen?.push({ name, input }); + const tool = tools.find((t) => t.name === name); + if (!tool) return { content: `Error: unknown tool \`${name}\`.`, isError: true }; + if (tool.handle) return tool.handle(input); + return { content: `dispatched:${name}` }; + }, + async withPrivacy(_handle, fn) { + if (privacyInstalled) privacyInstalled.value = true; + return fn(); + }, + }; +} + +/** + * A dispatcher backed by the REAL `ToolDispatchService`, with the real privacy + * data-plane boundary wired the way `wirePublicMcp.ts` wires it. + * + * Used for the PII assertions. A fake dispatcher could be made to "look masked" + * by returning masked text, which would prove nothing — these tests must show + * that a tool returning genuine PII produces an HTTP response WITHOUT that PII, + * because the real `afterDispatch` pipeline ran. + */ +export function realDispatcher( + tools: readonly FakeTool[], + seen?: { name: string; input: unknown }[], + opts?: { + /** + * Which of `ToolDispatchService`'s TWO dispatch branches to route through. + * They are separate code paths with separately-written privacy handling, so + * a guarantee proven on one proves nothing about the other. + */ + readonly via?: 'native' | 'domain'; + }, +): PublicMcpDispatcher { + const registry = new NativeToolRegistry(); + const domainTools: DomainTool[] = []; + const run = async (tool: FakeTool, input: unknown): Promise => { + seen?.push({ name: tool.name, input }); + const result = tool.handle ? await tool.handle(input) : { content: `dispatched:${tool.name}` }; + return result.content; + }; + + for (const tool of tools) { + if (opts?.via === 'domain') { + domainTools.push({ + name: tool.name, + spec: { + name: tool.name, + description: `desc:${tool.name}`, + input_schema: { type: 'object' as const, properties: {}, required: [] }, + }, + domain: 'domain.test', + handle: (input: unknown) => run(tool, input), + ...(tool.writeCapabilities ? { writeCapabilities: tool.writeCapabilities } : {}), + } as unknown as DomainTool); + continue; + } + registry.register(tool.name, { + handler: (input: unknown) => run(tool, input), + spec: { + name: tool.name, + description: `desc:${tool.name}`, + input_schema: { type: 'object' as const, properties: {} }, + }, + ...(tool.writeCapabilities ? { writeCapabilities: tool.writeCapabilities } : {}), + }); + } + + let slot: PrivacyTurnHandle | undefined; + const dispatch = new ToolDispatchService({ + nativeTools: registry, + domainTools, + // Explicit, exactly as production does it: this path runs outside any turn, + // so the ambient `turnContext` fallback is `undefined` here. + privacy: () => slot, + }); + + return { + dispatch: (name, input, options) => dispatch.dispatch(name, input, options), + listDispatchableToolSpecs: () => dispatch.listDispatchableToolSpecs(), + isWriteCapable: (name) => dispatch.isWriteCapable(name), + async withPrivacy(handle, fn) { + slot = handle; + try { + return await fn(); + } finally { + slot = undefined; + } + }, + }; +} + +/** + * A `PrivacyGuardService` stub that genuinely masks. + * + * `mask` replaces every email-looking span with `[email]`, so a test can assert + * the real address never reaches the wire. `failOn` makes `internToolResultV4` + * throw for one tool, which is the provider-error case the endpoint must fail + * CLOSED on (the dispatch layer's own behaviour there is fail-OPEN). + */ +export function maskingPrivacyService(opts?: { failOn?: string }): PrivacyGuardService { + return { + async internToolResultV4(request) { + if (opts?.failOn === request.toolName) { + throw new Error('privacy provider is unavailable'); + } + return { + digestText: request.rawResult.replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, '[email]'), + datasetId: 'ds-test', + }; + }, + async recordBypassedTool() {}, + async runV4Tool() { + return { resultText: '' }; + }, + async subAgentResultV4() { + return { resultText: '' }; + }, + async takeRenderedAnswerV4() { + return undefined; + }, + v4ToolSpecs() { + return []; + }, + async finalizeTurn() { + return undefined; + }, + } as unknown as PrivacyGuardService; +} + +/** A privacy provider function shaped the way `PublicMcpServerDeps` wants it. */ +export function privacyProviderFrom( + service: PrivacyGuardService | undefined, +): (scope: { sessionId: string; turnId: string }) => PrivacyTurnHandle | undefined { + return (scope) => + service + ? createPrivacyTurnHandle({ service, sessionId: scope.sessionId, turnId: scope.turnId }) + : undefined; +} + +export interface HarnessOptions { + readonly keys: readonly FakeKey[]; + /** Raw binding rows — normalized by the production code path, not bypassed. */ + readonly bindingRows: readonly Record[]; + /** agentId → dispatcher. An agent absent here is "not active". */ + readonly dispatchers: Readonly>; + /** Default true in the harness so the authorization tests are not all gated + * behind a privacy provider. Production defaults the OPPOSITE way (masking + * required); the privacy tests set this false explicitly. */ + readonly allowWithoutPrivacyMasking?: boolean; + /** Installed `privacyRedact` provider. Absent ⇒ none installed. */ + readonly privacyService?: PrivacyGuardService; + /** Full override of the per-dispatch handle factory, for tests that need a + * handle built with non-default options (e.g. a firing `resolveBypass`). */ + readonly privacy?: (scope: { + sessionId: string; + turnId: string; + }) => PrivacyTurnHandle | undefined; + /** + * Strips `PUBLIC_MCP_PATH` from the allowlist handed to `requireAuth`, to + * prove the entry is load-bearing rather than decorative. + */ + readonly withoutPublicPathEntry?: boolean; + readonly audit?: PublicMcpAuditEntry[]; + readonly toolTimeoutMs?: number; + readonly maxConcurrentCalls?: number; +} + +export interface Harness { + readonly url: string; + readonly app: Express; + readonly mounted: boolean; + post(body: unknown, opts?: { token?: string; headers?: Record }): Promise; + rpc( + body: unknown, + opts?: { token?: string }, + ): Promise<{ status: number; payload: Record }>; + close(): Promise; +} + +/** A real session-cookie signer, so the "no cookie" 401 is the production one. */ +const SESSION_KEY = new TextEncoder().encode('harness-session-signing-key-32bytes!!'); + +export async function startHarness(opts: HarnessOptions): Promise { + const app = express(); + app.set('trust proxy', true); + + // (1) The SAME global parser index.ts installs. Its 10mb limit is why the + // endpoint's own 8 MB ceiling is enforced post-parse. + app.use(express.json({ limit: '10mb' })); + + const allowlist = opts.withoutPublicPathEntry + ? STATIC_PUBLIC_PATHS.filter((re) => !re.test(PUBLIC_MCP_PATH)) + : publicPaths({ devEndpointsEnabled: false }); + + const requireAuth = createRequireAuth({ + signingKey: SESSION_KEY, + whitelist: new EmailWhitelist('operator@example.com'), + publicPaths: allowlist, + }); + + // (2) The OB-106 line: requireAuth for every /api/* request, whichever router + // ultimately answers. The trailing router is a stand-in for createChatRouter — + // what matters is that the guard runs first for the whole prefix. + app.use('/api', requireAuth, express.Router()); + + // (3) The production wire function, not a re-implementation. + const audit = opts.audit; + const mounted = mountPublicMcp(app, requireAuth, { + enabled: true, + allowWithoutPrivacyMasking: opts.allowWithoutPrivacyMasking ?? true, + privacy: opts.privacy ?? privacyProviderFrom(opts.privacyService), + graphPool: undefined, + log: () => {}, + apiKeys: fakeApiKeyStore(opts.keys), + rateLimiter: createRateLimiter(), + bindings: createInMemoryPublicMcpKeyBindingStore( + opts.bindingRows as Parameters[0], + ), + resolveDispatcher: (agentId) => opts.dispatchers[agentId], + ...(audit ? { audit: (entry: PublicMcpAuditEntry) => audit.push(entry) } : {}), + ...(opts.toolTimeoutMs !== undefined ? { toolTimeoutMs: opts.toolTimeoutMs } : {}), + ...(opts.maxConcurrentCalls !== undefined + ? { maxConcurrentCalls: opts.maxConcurrentCalls } + : {}), + }); + + const server: Server = createServer(app); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + const { port } = server.address() as AddressInfo; + const url = `http://127.0.0.1:${String(port)}${PUBLIC_MCP_PATH}`; + + async function post( + body: unknown, + o?: { token?: string; headers?: Record }, + ): Promise { + return fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: MCP_ACCEPT, + ...(o?.token ? { Authorization: `Bearer ${o.token}` } : {}), + ...(o?.headers ?? {}), + }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); + } + + return { + url, + app, + mounted, + post, + async rpc(body, o) { + const res = await post(body, o); + const text = await res.text(); + return { + status: res.status, + payload: text.length > 0 ? parseMcpJson(text) : {}, + }; + }, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +/** A `tools/list` JSON-RPC request. Stateless: no `initialize`, no session id. */ +export function listToolsRequest(id = 1): unknown { + return { jsonrpc: '2.0', method: 'tools/list', params: {}, id }; +} + +/** A `tools/call` JSON-RPC request. */ +export function callToolRequest(name: string, args: unknown = {}, id = 2): unknown { + return { jsonrpc: '2.0', method: 'tools/call', params: { name, arguments: args }, id }; +} + +/** Tool names from a `tools/list` reply, or `undefined` when it errored. */ +export function toolNames(payload: Record): string[] | undefined { + const result = payload['result'] as { tools?: { name: string }[] } | undefined; + return result?.tools?.map((t) => t.name); +} + +/** The JSON-RPC error message, or undefined when the reply succeeded. */ +export function rpcErrorMessage(payload: Record): string | undefined { + const err = payload['error'] as { message?: string } | undefined; + if (err?.message !== undefined) return err.message; + // A tool-level failure surfaces as a successful RPC with isError set. + const result = payload['result'] as + | { isError?: boolean; content?: { text?: string }[] } + | undefined; + if (result?.isError) return result.content?.[0]?.text; + return undefined; +} + +/** The text content of a successful `tools/call` reply. */ +export function callResultText(payload: Record): string | undefined { + const result = payload['result'] as { content?: { text?: string }[] } | undefined; + return result?.content?.[0]?.text; +} diff --git a/middleware/test/publicMcp/mutation-check.sh b/middleware/test/publicMcp/mutation-check.sh new file mode 100644 index 00000000..c0bc2e95 --- /dev/null +++ b/middleware/test/publicMcp/mutation-check.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# W2-3 (issue #542) — mutation harness for the public MCP endpoint. +# +# Counting mock invocations proves nothing about whether a gate works. Each entry +# below deliberately BREAKS one invariant with a real source edit, re-runs the +# suite, and requires a real assertion failure. A mutation that leaves the suite +# green means the invariant is untested — the check FAILS in that direction, which +# is the whole point. +# +# Usage: bash test/publicMcp/mutation-check.sh +# Run from middleware/. Reverts every edit via `git checkout --` afterwards. +set -uo pipefail + +cd "$(dirname "$0")/../.." || exit 1 + +# ── Guard: never run against a dirty tree ─────────────────────────────────── +# This harness EDITS tracked source files in place and restores them with +# `git checkout --`. Two failure modes made that dangerous in practice, and both +# actually happened during development: +# +# 1. A `git add -A && git commit` running CONCURRENTLY with this script swept a +# mid-mutation file into a commit — the per-tool timeout shipped as +# `return 2_147_483_647` (i.e. disabled) inside a docs commit. +# 2. Killing the script mid-run leaves the last mutation applied, and the next +# `git checkout --` then restores it from a commit that already contains it. +# +# Refusing to start on a dirty tree makes (1) detectable — you cannot have +# uncommitted work in flight — and `verify_clean` at the end makes (2) loud. +# NEVER commit while this script is running. +if ! git diff --quiet -- "$@" 2>/dev/null; then + if [ -n "$(git status --porcelain -- packages/harness-api-key-auth/src src/mcp src/auth)" ]; then + echo "✖ REFUSING TO RUN: uncommitted changes in the files this harness mutates." + echo " Commit or stash them first — a concurrent commit can capture a mutation." + git status --porcelain -- packages/harness-api-key-auth/src src/mcp src/auth + exit 2 + fi +fi +SCOPES=packages/harness-api-key-auth/src/apiKeyScopes.ts +SERVER=src/mcp/publicMcpServer.ts +BINDINGS=src/mcp/publicMcpKeyBindings.ts +PATHS=src/auth/publicPaths.ts +PRIVACY=src/mcp/publicMcpPrivacy.ts + +TESTS=('test/publicMcp/publicMcpScopes.test.ts' + 'test/publicMcp/publicMcpKeyBindings.test.ts' + 'test/publicMcp/publicMcpBodyCap.test.ts' + 'test/publicMcp/publicMcpEndpoint.e2e.test.ts' + 'test/publicMcp/publicMcpPrivacy.e2e.test.ts' + 'test/publicMcp/publicMcpMaskingAssertion.test.ts' + 'test/publicPaths.test.ts') + +pass=0; fail=0 + +revert() { git checkout -- "$SCOPES" "$SERVER" "$BINDINGS" "$PATHS" "$PRIVACY" 2>/dev/null; } + +# run_mutation ::`. A generic rule would legalize every mistyped triple, and + * each such string would validate, persist, and grant nothing — which reads + * exactly like a revoked key at debug time. + */ + it('does NOT admit an arbitrary three-segment scope', () => { + assert.equal(isValidScope('odoo:write:invoice'), false); + assert.equal(isValidScope('mcp:read:thing'), false); + assert.equal(isValidScope('mcp:write:'), false); + assert.equal(isValidScope('mcp:write:Create_Lead'), false); + assert.equal(isValidScope('mcp:write:1tool'), false); + }); + + /** + * `mcp:write` is a well-formed TWO-segment scope, so the generic pattern + * accepts it — and it is the likeliest thing an operator types meaning "this + * key may write". It would grant nothing (no check ever asks for it), which + * reads exactly like a revoked key. Rejected outright so the mistake surfaces + * at mint time. There is no class-wide write scope by design. + */ + it('rejects the bare mcp:write, which would validate and grant nothing', () => { + assert.equal(isValidScope('mcp:write'), false); + assert.throws(() => assertValidScopes(['mcp:write']), /invalid API-key scope/); + assert.deepEqual(normalizeScopes(['mcp:write']), DENY_ALL_SCOPES); + }); + + it('still admits and rejects exactly what it did before, for two-segment scopes', () => { + assert.equal(isValidScope('chat:write'), true); + assert.equal(isValidScope('memory:read'), true); + assert.equal(isValidScope(WILDCARD_SCOPE), true); + assert.equal(isValidScope('Chat:Write'), false); + assert.equal(isValidScope('nonsense'), false); + assert.equal(isValidScope(42), false); + assert.equal(isValidScope(null), false); + }); + + it('identifies write scopes by prefix', () => { + assert.equal(isMcpWriteScope('mcp:write:x'), true); + assert.equal(isMcpWriteScope(MCP_INVOKE_SCOPE), false); + assert.equal(isMcpWriteScope(WILDCARD_SCOPE), false); + }); +}); + +describe('MCP scopes — the wildcard exclusion', () => { + /** + * THE load-bearing assertion of this file. `*` grants every other capability + * and must grant no write. + */ + it('WILDCARD_SCOPE does NOT grant a per-tool write', () => { + const granted = [WILDCARD_SCOPE]; + assert.equal(hasScope(granted, mcpWriteScope('create_lead')), false); + assert.equal(hasWriteScope(granted, 'create_lead'), false); + }); + + it('WILDCARD_SCOPE still grants every non-write scope', () => { + const granted = [WILDCARD_SCOPE]; + assert.equal(hasScope(granted, MCP_LIST_SCOPE), true); + assert.equal(hasScope(granted, MCP_INVOKE_SCOPE), true); + assert.equal(hasScope(granted, 'chat:write'), true); + }); + + it('grants a write ONLY on an exact per-tool match', () => { + const granted = [MCP_INVOKE_SCOPE, mcpWriteScope('create_lead')]; + assert.equal(hasWriteScope(granted, 'create_lead'), true); + // A sibling write tool is a different capability. + assert.equal(hasWriteScope(granted, 'delete_invoice'), false); + // And the write scope does not backfill the invoke scope for another tool. + assert.equal(hasWriteScope([mcpWriteScope('create_lead')], 'create_lead'), true); + }); + + it('mcp:invoke alone does NOT grant any write', () => { + assert.equal(hasWriteScope([MCP_INVOKE_SCOPE], 'create_lead'), false); + }); + + it('hasWriteScope and hasScope agree — so calling the wrong one is not a security event', () => { + for (const granted of [ + [WILDCARD_SCOPE], + [MCP_INVOKE_SCOPE], + [mcpWriteScope('t')], + [], + ]) { + assert.equal( + hasWriteScope(granted, 't'), + hasScope(granted, mcpWriteScope('t')), + `disagreement for granted=${JSON.stringify(granted)}`, + ); + } + }); + + it('denies everything when nothing is granted', () => { + assert.equal(hasScope(undefined, MCP_LIST_SCOPE), false); + assert.equal(hasScope(DENY_ALL_SCOPES, MCP_INVOKE_SCOPE), false); + assert.equal(hasWriteScope(DENY_ALL_SCOPES, 'create_lead'), false); + }); +}); + +describe('MCP scopes — persisted-record normalization', () => { + it('round-trips a valid persisted write scope', () => { + const normalized = normalizeScopes([MCP_INVOKE_SCOPE, 'mcp:write:create_lead']); + assert.deepEqual([...normalized].sort(), ['mcp:invoke', 'mcp:write:create_lead']); + assert.equal(hasWriteScope(normalized, 'create_lead'), true); + }); + + /** A malformed persisted `scopes` field must deny EVERYTHING, including the + * write scopes that happen to sit next to the malformed entry. */ + it('malformed persisted scopes deny all — a valid write scope alongside garbage grants nothing', () => { + const normalized = normalizeScopes(['mcp:write:create_lead', 'NOT A SCOPE']); + assert.deepEqual(normalized, DENY_ALL_SCOPES); + assert.equal(hasWriteScope(normalized, 'create_lead'), false); + assert.equal(hasScope(normalized, MCP_LIST_SCOPE), false); + }); + + it('a non-array persisted scopes field denies all', () => { + assert.deepEqual(normalizeScopes('mcp:invoke'), DENY_ALL_SCOPES); + }); + + it('an empty persisted scopes array denies all', () => { + assert.deepEqual(normalizeScopes([]), DENY_ALL_SCOPES); + }); + + /** An absent field is a genuine pre-#439 key and keeps its old capability — + * it must NOT be widened to the new MCP surface by an upgrade. */ + it('an absent scopes field stays chat-only and reaches no MCP capability', () => { + const normalized = normalizeScopes(undefined); + assert.deepEqual(normalized, LEGACY_DEFAULT_SCOPES); + assert.equal(hasScope(normalized, MCP_LIST_SCOPE), false); + assert.equal(hasScope(normalized, MCP_INVOKE_SCOPE), false); + assert.equal(hasWriteScope(normalized, 'create_lead'), false); + }); + + it('accepts MCP scopes at creation time and rejects a malformed one', () => { + assert.deepEqual( + [...assertValidScopes([MCP_LIST_SCOPE, MCP_INVOKE_SCOPE, 'mcp:write:create_lead'])].sort(), + ['mcp:invoke', 'mcp:list', 'mcp:write:create_lead'], + ); + assert.throws(() => assertValidScopes(['mcp:write:Create_Lead']), /invalid API-key scope/); + }); +}); diff --git a/middleware/test/publicPaths.test.ts b/middleware/test/publicPaths.test.ts index d690bac0..dc722b36 100644 --- a/middleware/test/publicPaths.test.ts +++ b/middleware/test/publicPaths.test.ts @@ -2,6 +2,8 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { publicPaths, STATIC_PUBLIC_PATHS } from '../src/auth/publicPaths.js'; +import { CIMD_METADATA_PATH } from '../src/services/mcpCimd.js'; +import { PUBLIC_MCP_PATH } from '../src/mcp/publicMcpPath.js'; /** * Regression guard for the MCP-OAuth-callback 401 bug: the epic #459 W9 @@ -55,3 +57,108 @@ describe('publicPaths — MCP OAuth callback allowlist', () => { ); }); }); + +/** + * W2-4 (issue #546) — the Client ID Metadata Document. An authorization server + * fetches it with NO credential of ours; that is the entire mechanism (the + * `client_id` we hand the AS is this URL, which it dereferences). So it must be + * public, and it must be public via the SHARED constant: asserting against a + * retyped literal here is precisely the drift this module's doc comment warns + * about, so the test derives its expectation from `CIMD_METADATA_PATH` itself. + */ +describe('publicPaths — MCP client-ID metadata document allowlist', () => { + const allowlist = publicPaths({ devEndpointsEnabled: false }); + + it('allows the metadata document path built from the shared constant', () => { + assert.equal( + allowlist.some((p) => p.test(CIMD_METADATA_PATH)), + true, + `${CIMD_METADATA_PATH} must be public — an AS fetches it uncredentialed`, + ); + }); + + it('allows it with a query string appended', () => { + assert.equal( + allowlist.some((p) => p.test(`${CIMD_METADATA_PATH}?v=1`)), + true, + ); + }); + + it('is present in STATIC_PUBLIC_PATHS regardless of devEndpointsEnabled', () => { + assert.equal( + STATIC_PUBLIC_PATHS.some((p) => p.test(CIMD_METADATA_PATH)), + true, + ); + }); + + it('does NOT widen the bypass to a sibling well-known path', () => { + assert.equal( + allowlist.some((p) => p.test(`${CIMD_METADATA_PATH}-secret`)), + false, + 'a prefix match would expose neighbouring well-known routes', + ); + assert.equal( + allowlist.some((p) => p.test('/.well-known/omadia-mcp-client/../../api/v1/operator/x')), + false, + ); + }); +}); + +/** + * W2-3 (issue #542) — the public, stateless MCP endpoint. + * + * Asserted against the SHARED `PUBLIC_MCP_PATH` constant, not a retyped + * literal, for the reason in this module's own doc comment: a hand-written + * pattern next to a hand-written mount is exactly the epic #470 drift the + * constant exists to make impossible. + * + * The entry is what makes the route reachable at all — the OB-106 `/api` + * requireAuth line runs for every `/api/*` request — so its removal makes the + * endpoint go DARK rather than open. That failure direction is asserted + * end-to-end in `test/publicMcp/publicMcpEndpoint.e2e.test.ts` + * ("goes DARK (session 401), not open"); this block covers the allowlist half. + */ +describe('publicPaths — public MCP endpoint allowlist', () => { + const allowlist = publicPaths({ devEndpointsEnabled: false }); + const isPublic = (path: string): boolean => allowlist.some((p) => p.test(path)); + + it('exempts the public MCP endpoint from the session gate', () => { + assert.equal( + isPublic(PUBLIC_MCP_PATH), + true, + `${PUBLIC_MCP_PATH} must be exempt — it authenticates via requireApiKey`, + ); + }); + + it('exempts it with a query string appended', () => { + assert.equal(isPublic(`${PUBLIC_MCP_PATH}?v=1`), true); + }); + + it('is present in STATIC_PUBLIC_PATHS regardless of devEndpointsEnabled', () => { + assert.equal( + STATIC_PUBLIC_PATHS.some((p) => p.test(PUBLIC_MCP_PATH)), + true, + ); + }); + + /** + * Narrowest possible entry. Every additional character this matched would be + * a new unauthenticated-until-the-handler-says-otherwise surface, and the + * NOTE in `publicPaths.ts` asks for exactly one route, never a prefix. + */ + it('does NOT widen the bypass to sub-paths under the endpoint', () => { + assert.equal(isPublic(`${PUBLIC_MCP_PATH}/admin`), false); + assert.equal(isPublic(`${PUBLIC_MCP_PATH}/`), false); + }); + + it('does NOT widen the bypass to sibling /api/v1/mcp* routes', () => { + assert.equal(isPublic('/api/v1/mcp-servers'), false); + assert.equal(isPublic('/api/v1/mcp-oauth/callback'), false); + assert.equal(isPublic('/api/v1/mcpsecret'), false); + }); + + it('does NOT exempt the operator MCP admin surfaces', () => { + assert.equal(isPublic('/api/v1/operator/mcp-servers'), false); + assert.equal(isPublic('/api/v1/operator/mcp-call-log'), false); + }); +}); diff --git a/middleware/test/skillToolBindings.test.ts b/middleware/test/skillToolBindings.test.ts index d7b79d88..9e9b455d 100644 --- a/middleware/test/skillToolBindings.test.ts +++ b/middleware/test/skillToolBindings.test.ts @@ -88,6 +88,8 @@ function makeDeps( }); return 'ok'; }, + // Issue #547 (W1-3): adapters seed the output-schema cache; no-op here. + rememberToolSchema: () => {}, } as unknown as McpManager, mcpServers: [server()], defaultModel: 'claude-sonnet-4-6', diff --git a/middleware/test/subAgentToolHydrationTopLevel.test.ts b/middleware/test/subAgentToolHydrationTopLevel.test.ts index f1a51bb0..b9c17b28 100644 --- a/middleware/test/subAgentToolHydrationTopLevel.test.ts +++ b/middleware/test/subAgentToolHydrationTopLevel.test.ts @@ -90,6 +90,9 @@ function fakeDeps(calls: Array<{ server: string; tool: string; args: Record {}, } as unknown as McpManager; return { client: {} as unknown as AnthropicClient, @@ -187,7 +190,10 @@ describe('registerDbSubAgentTools: top-level MCP grants (#457)', () => { describe('mcpGrantToDomainTool', () => { it('carries description and input schema from the discovered descriptor', () => { - const manager = { callTool: async () => 'x' } as unknown as McpManager; + const manager = { + callTool: async () => 'x', + rememberToolSchema: () => {}, + } as unknown as McpManager; const tool = mcpGrantToDomainTool( manager, { id: SERVER_ID, name: 'billing', transport: 'http', endpoint: 'http://x' }, diff --git a/middleware/test/tasks/inMemoryTaskStore.test.ts b/middleware/test/tasks/inMemoryTaskStore.test.ts new file mode 100644 index 00000000..d6a402bc --- /dev/null +++ b/middleware/test/tasks/inMemoryTaskStore.test.ts @@ -0,0 +1,531 @@ +import { strict as assert } from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { describe, it } from 'node:test'; + +import { + InMemoryTaskStore, + TaskLeaseLostError, + runTaskReaperOnce, + startTaskReaper, +} from '@omadia/orchestrator'; + +/** + * W2-2 — the seam's claim/lease + terminal-transition + orphan-reaper + * semantics, on the reference implementor. + * + * These are behaviour assertions, not mock-call counts: every test here fails + * for a real reason if the invariant is broken (verified by deliberately + * breaking each one, rebuilding, and confirming a failure — see the delivery + * report's mutation-check table). + */ + +function driven(startMs: number): { store: InMemoryTaskStore; advance: (ms: number) => void; nowMs: () => number } { + let clock = startMs; + const store = new InMemoryTaskStore({ clock: () => clock }); + return { + store, + advance: (ms: number): void => { + clock += ms; + }, + nowMs: (): number => clock, + }; +} + +describe('tasks/InMemoryTaskStore — claim + lease', () => { + it('creates a task as `working` with no lease', async () => { + const store = new InMemoryTaskStore(); + const t = await store.create({ kind: 'k', input: { a: 1 } }); + assert.equal(t.status, 'working'); + assert.equal(t.claimedBy, null); + assert.equal(t.phase, 'queued'); + assert.equal(t.endedAt, null); + }); + + it('rejects a non-UUID lease loudly instead of coercing it', async () => { + const store = new InMemoryTaskStore(); + await store.create({ kind: 'k', input: {} }); + await assert.rejects(() => store.claimNextPending('not-a-uuid'), TypeError); + }); + + it('hands one task to exactly ONE of two concurrent claimers', async () => { + const store = new InMemoryTaskStore(); + const created = await store.create({ kind: 'k', input: { n: 1 } }); + + const leaseA = randomUUID(); + const leaseB = randomUUID(); + const [a, b] = await Promise.all([ + store.claimNextPending(leaseA), + store.claimNextPending(leaseB), + ]); + + const winners = [a, b].filter((r) => r !== null); + assert.equal(winners.length, 1, 'exactly one claimer must win'); + assert.equal(winners[0]?.descriptor.id, created.id); + // And the winner's lease is the one actually stamped on the row. + const after = await store.get(created.id); + assert.ok(after?.claimedBy === leaseA || after?.claimedBy === leaseB); + assert.equal(after?.claimedBy, winners[0]?.descriptor.claimedBy); + }); + + it('claims oldest-first', async () => { + const { store, advance } = driven(1_000); + const first = await store.create({ kind: 'k', input: { n: 1 } }); + advance(1_000); + await store.create({ kind: 'k', input: { n: 2 } }); + + const claimed = await store.claimNextPending(randomUUID()); + assert.equal(claimed?.descriptor.id, first.id); + }); + + it('honours a kind filter and leaves other kinds unclaimed', async () => { + const store = new InMemoryTaskStore(); + const other = await store.create({ kind: 'other', input: {} }); + const wanted = await store.create({ kind: 'wanted', input: {} }); + + const claimed = await store.claimNextPending(randomUUID(), 'wanted'); + assert.equal(claimed?.descriptor.id, wanted.id); + assert.equal((await store.get(other.id))?.claimedBy, null); + }); + + it('returns the stored input to the claimer', async () => { + const store = new InMemoryTaskStore(); + await store.create({ kind: 'k', input: { question: 'how many?' } }); + const claimed = await store.claimNextPending(randomUUID()); + assert.deepEqual(claimed?.input, { question: 'how many?' }); + }); + + it('FENCES every write on the lease — a stale lease cannot mutate', async () => { + const store = new InMemoryTaskStore(); + const t = await store.create({ kind: 'k', input: {} }); + const good = randomUUID(); + await store.claimNextPending(good); + const stale = randomUUID(); + + await assert.rejects(() => store.heartbeat(t.id, stale), TaskLeaseLostError); + await assert.rejects(() => store.setPhase(t.id, stale, 'x'), TaskLeaseLostError); + await assert.rejects( + () => store.appendEvents(t.id, stale, [{ type: 'log', message: 'm' }]), + TaskLeaseLostError, + ); + await assert.rejects( + () => store.finish(t.id, stale, { status: 'completed', result: 'hijacked' }), + TaskLeaseLostError, + ); + + // The state the stale lease tried to write must NOT be there. + const after = await store.get(t.id); + assert.equal(after?.status, 'working'); + assert.equal(after?.phase, 'queued'); + assert.equal(after?.result, null); + assert.deepEqual(await store.eventTail(t.id, 10), []); + }); + + it('refuses any write once terminal — the outcome is immutable', async () => { + const store = new InMemoryTaskStore(); + const t = await store.create({ kind: 'k', input: {} }); + const lease = randomUUID(); + await store.claimNextPending(lease); + await store.finish(t.id, lease, { status: 'completed', result: 'real answer' }); + + // Even the ORIGINAL, correct lease cannot reopen or overwrite it. + await assert.rejects( + () => store.finish(t.id, lease, { status: 'failed', error: 'nope' }), + TaskLeaseLostError, + ); + await assert.rejects(() => store.heartbeat(t.id, lease), TaskLeaseLostError); + + const after = await store.get(t.id); + assert.equal(after?.status, 'completed'); + assert.equal(after?.result, 'real answer'); + assert.equal(after?.error, null); + }); + + it('a terminal transition releases the lease and stamps endedAt', async () => { + const store = new InMemoryTaskStore(); + const t = await store.create({ kind: 'k', input: {} }); + const lease = randomUUID(); + await store.claimNextPending(lease); + const done = await store.finish(t.id, lease, { status: 'failed', error: 'boom' }); + + assert.equal(done.status, 'failed'); + assert.equal(done.error, 'boom'); + assert.equal(done.claimedBy, null); + assert.ok(done.endedAt !== null); + }); + + it('requireInput releases the lease so the task can be re-claimed', async () => { + const store = new InMemoryTaskStore(); + const t = await store.create({ kind: 'k', input: {} }); + const lease = randomUUID(); + await store.claimNextPending(lease); + const gated = await store.requireInput(t.id, lease, 'awaiting_human'); + + assert.equal(gated.status, 'input_required'); + assert.equal(gated.claimedBy, null); + // `input_required` is NOT claimable — only a `working` task is, so the claim + // loop leaves a human-gated task alone instead of spinning on it. + assert.equal(await store.claimNextPending(randomUUID()), null); + }); + + it('MUTATION CHECK: the taskId hint claims THAT task, not the pool head', async () => { + // W4. Without the hint a runner spawned for task B is handed the oldest + // unclaimed task instead — the crossed claim that stranded two tasks for a + // full orphan window. Asserted at the store, because the runner has its own + // defence-in-depth layer that would mask a broken filter here. + const store = new InMemoryTaskStore(); + const oldest = await store.create({ kind: 'k', input: { which: 'oldest' } }); + const wanted = await store.create({ kind: 'k', input: { which: 'wanted' } }); + + const claimed = await store.claimNextPending(randomUUID(), 'k', wanted.id); + assert.ok(claimed); + assert.equal( + claimed.descriptor.id, + wanted.id, + 'the hint was ignored and the pool head was claimed instead', + ); + assert.deepEqual(claimed.input, { which: 'wanted' }); + // The task it skipped is untouched and still claimable by its own runner. + assert.equal((await store.get(oldest.id))?.claimedBy, null); + }); + + it('returns null — rather than someone else\'s task — when the hinted task is gone', async () => { + const store = new InMemoryTaskStore(); + await store.create({ kind: 'k', input: {} }); + assert.equal( + await store.claimNextPending(randomUUID(), 'k', randomUUID()), + null, + 'an unclaimable hint must claim NOTHING, never fall back to the pool head', + ); + }); + + it('keeps a bounded event tail in order', async () => { + const store = new InMemoryTaskStore({ maxEvents: 3 }); + const t = await store.create({ kind: 'k', input: {} }); + const lease = randomUUID(); + await store.claimNextPending(lease); + for (const n of [1, 2, 3, 4, 5]) { + await store.appendEvents(t.id, lease, [{ type: 'log', message: `e${String(n)}` }]); + } + const tail = await store.eventTail(t.id, 10); + assert.deepEqual( + tail.map((e) => e.message), + ['e3', 'e4', 'e5'], + 'oldest lines drop, newest survive, order preserved', + ); + assert.deepEqual(tail.map((e) => e.seq), [3, 4, 5], 'seq stays monotonic'); + }); +}); + +describe('tasks/orphan reaper', () => { + it('force-fails a live task whose worker went silent', async () => { + const { store, advance } = driven(10_000); + const t = await store.create({ kind: 'k', input: {} }); + const lease = randomUUID(); + await store.claimNextPending(lease); + + // Not yet stale: the sweep must leave it alone. + advance(60_000); + let r = await runTaskReaperOnce( + store, + { staleAfterMs: 300_000, purgeTerminalAfterMs: 3_600_000 }, + new Date(10_000 + 60_000), + ); + assert.equal(r.staleFailed, 0); + assert.equal((await store.get(t.id))?.status, 'working'); + + // Now past the window. + advance(300_000); + r = await runTaskReaperOnce( + store, + { staleAfterMs: 300_000, purgeTerminalAfterMs: 3_600_000 }, + new Date(10_000 + 360_000), + ); + assert.equal(r.staleFailed, 1); + const after = await store.get(t.id); + assert.equal(after?.status, 'failed'); + assert.match(String(after?.error), /abandoned/); + }); + + it('a ZOMBIE worker cannot overwrite what the reaper recorded', async () => { + // The race the terminal guard exists for, and the only path that reaches it: + // the reaper preserves `claimedBy`, so a worker that wakes up after being + // reaped still presents a MATCHING lease. Only the terminal check stops it. + const { store } = driven(0); + const t = await store.create({ kind: 'k', input: {} }); + const lease = randomUUID(); + await store.claimNextPending(lease); + + await runTaskReaperOnce( + store, + { staleAfterMs: 1_000, purgeTerminalAfterMs: 3_600_000 }, + new Date(500_000), + ); + const reaped = await store.get(t.id); + assert.equal(reaped?.status, 'failed'); + assert.equal(reaped?.claimedBy, lease, 'the reaper keeps the lease on record'); + + // Same lease, still matching — every write must still be refused. + await assert.rejects( + () => store.finish(t.id, lease, { status: 'completed', result: 'resurrected' }), + TaskLeaseLostError, + ); + await assert.rejects(() => store.heartbeat(t.id, lease), TaskLeaseLostError); + await assert.rejects( + () => store.appendEvents(t.id, lease, [{ type: 'log', message: 'zombie' }]), + TaskLeaseLostError, + ); + + const final = await store.get(t.id); + assert.equal(final?.status, 'failed'); + assert.equal(final?.result, null, 'the zombie result never landed'); + assert.match(String(final?.error), /abandoned/); + }); + + it('reaps a task that was NEVER claimed (no worker ever started)', async () => { + // The leak the reaper exists for: nothing claimed it, so lastHeartbeatAt is + // null and a naive sweep keyed only on the heartbeat would skip it forever. + const { store } = driven(0); + const t = await store.create({ kind: 'k', input: {} }); + assert.equal((await store.get(t.id))?.lastHeartbeatAt, null); + + const r = await runTaskReaperOnce( + store, + { staleAfterMs: 1_000, purgeTerminalAfterMs: 3_600_000 }, + new Date(500_000), + ); + assert.equal(r.staleFailed, 1); + assert.equal((await store.get(t.id))?.status, 'failed'); + }); + + it('purges terminal tasks past the retain window and keeps in-window ones', async () => { + const { store } = driven(0); + const old = await store.create({ kind: 'k', input: {} }); + const leaseOld = randomUUID(); + await store.claimNextPending(leaseOld); + await store.finish(old.id, leaseOld, { status: 'completed', result: 'a' }); + + const r = await runTaskReaperOnce( + store, + { staleAfterMs: 1_000_000, purgeTerminalAfterMs: 60_000 }, + new Date(120_000), + ); + assert.equal(r.purged, 1); + assert.equal(await store.get(old.id), null, 'purged task is gone'); + + // A fresh terminal task survives the same sweep. + const fresh = await store.create({ kind: 'k', input: {} }); + const leaseFresh = randomUUID(); + await store.claimNextPending(leaseFresh); + await store.finish(fresh.id, leaseFresh, { status: 'completed', result: 'b' }); + const r2 = await runTaskReaperOnce( + store, + { staleAfterMs: 1_000_000, purgeTerminalAfterMs: 3_600_000 }, + new Date(120_001), + ); + assert.equal(r2.purged, 0); + assert.ok(await store.get(fresh.id)); + }); + + it('rejects non-positive reap windows rather than sweeping everything', async () => { + const store = new InMemoryTaskStore(); + await assert.rejects( + () => store.reapOrphans({ staleAfterMs: 0, purgeTerminalAfterMs: 1 }), + TypeError, + ); + await assert.rejects( + () => store.reapOrphans({ staleAfterMs: 1, purgeTerminalAfterMs: -1 }), + TypeError, + ); + await assert.rejects( + () => + store.reapOrphans({ + staleAfterMs: 1, + purgeTerminalAfterMs: 1, + parkedStaleAfterMs: 0, + }), + TypeError, + ); + }); +}); + +describe('tasks/startTaskReaper — sweeps do not overlap (W4)', () => { + it('MUTATION CHECK: a sweep slower than the interval is not re-entered', async () => { + // `setInterval` does not await its callback. Harmless against this store + // (its `reapOrphans` never suspends, so it is one atomic JS task), but the + // seam exists precisely so a Postgres implementor can back it — and there a + // slow sweep would stack concurrent transactions contending on the same rows. + let concurrent = 0; + let maxConcurrent = 0; + let starts = 0; + let release!: () => void; + const inSweep = new Promise((r) => { + release = r; + }); + + const slowStore = { + reapOrphans: async () => { + starts += 1; + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await inSweep; + concurrent -= 1; + return { staleFailed: 0, purged: 0 }; + }, + } as unknown as InMemoryTaskStore; + + const stop = startTaskReaper(slowStore, { intervalMs: 1 }); + try { + // Several intervals elapse while the first sweep is still running. + await new Promise((r) => setTimeout(r, 60)); + assert.equal( + maxConcurrent, + 1, + `a sweep was re-entered while still running (${String(maxConcurrent)} concurrent)`, + ); + assert.equal(starts, 1, 'only one sweep may be in flight at a time'); + release(); + // Once it finishes, the schedule resumes. + await new Promise((r) => setTimeout(r, 30)); + assert.ok(starts > 1, 'the reaper must keep sweeping after one completes'); + } finally { + release(); + stop(); + } + }); +}); + +/** + * W4 — the orphan sweep must not kill a task that is waiting on a HUMAN. + * + * `requireInput` releases the lease and freezes `lastHeartbeatAt`, and nothing + * heartbeats a parked row — there is no worker left to do it. Judging a parked + * task by the worker-liveness window therefore force-failed every card the user + * took longer than the orphan threshold to answer, so an answer typed 16 minutes + * after the card appeared landed on a task already marked `failed` with "task + * abandoned: no worker heartbeat". + */ +describe('tasks/InMemoryTaskStore — human-parked tasks vs the orphan sweep (W4)', () => { + /** Park a task on a human, exactly as a gate would. */ + async function park( + store: InMemoryTaskStore, + ): Promise<{ id: string; lease: string }> { + const t = await store.create({ kind: 'k', input: { q: 'approve?' } }); + const lease = randomUUID(); + const claimed = await store.claimNextPending(lease, undefined, t.id); + assert.ok(claimed, 'the task must be claimable before it parks'); + await store.requireInput(t.id, lease, 'awaiting_human'); + return { id: t.id, lease }; + } + + it('MUTATION CHECK: a parked task survives well past the orphan threshold and is still answerable', async () => { + const { store, advance, nowMs } = driven(1_000_000); + const parked = await park(store); + // A genuinely abandoned `working` sibling, so this test proves the sweep RAN + // and did its job — not merely that it was a no-op. + const abandoned = await store.create({ kind: 'k', input: {} }); + + // The user takes 16 minutes to read the card and type an answer — one minute + // past the shipped 15-minute orphan window. + advance(16 * 60_000); + const swept = await runTaskReaperOnce(store, {}, new Date(nowMs())); + + assert.equal(swept.staleFailed, 1, 'exactly the abandoned sibling is reaped'); + assert.equal( + (await store.get(abandoned.id))?.status, + 'failed', + 'the sweep must still reap a real orphan', + ); + + const row = await store.get(parked.id); + assert.ok(row); + assert.equal( + row.status, + 'input_required', + 'a task waiting on a human was force-failed by the worker-liveness sweep', + ); + assert.equal(row.error, null, 'no abandonment error was written'); + assert.equal(row.endedAt, null, 'the parked task is still live'); + + // Still answerable: the row is non-terminal, so the gate that owns it can + // still drive it to an outcome. Under the bug this write was impossible — + // `fenced()` rejects any write to a terminal row, so the human's answer had + // nowhere to land. + const answerLease = randomUUID(); + await store.heartbeat(parked.id, parked.lease).then( + () => assert.fail('a released lease must not still write'), + (err: unknown) => assert.ok(err instanceof TaskLeaseLostError), + ); + void answerLease; + assert.equal((await store.get(parked.id))?.status, 'input_required'); + + // …and hours later it is STILL there, because there is no default window. + advance(6 * 60 * 60_000); + const later = await runTaskReaperOnce(store, {}, new Date(nowMs())); + assert.equal(later.staleFailed, 0); + assert.equal((await store.get(parked.id))?.status, 'input_required'); + }); + + it('MUTATION CHECK: the explicit parked window DOES expire it, with its own error', async () => { + // The escape hatch must actually work, or "excluded from the sweep" would + // quietly mean "immortal". Opt-in, measured from when the task parked. + const { store, advance, nowMs } = driven(1_000_000); + const parked = await park(store); + + advance(30 * 60_000); + const tooSoon = await runTaskReaperOnce( + store, + { parkedStaleAfterMs: 60 * 60_000 }, + new Date(nowMs()), + ); + assert.equal(tooSoon.staleFailed, 0, 'inside its own window it survives'); + + advance(31 * 60_000); + const swept = await runTaskReaperOnce( + store, + { parkedStaleAfterMs: 60 * 60_000 }, + new Date(nowMs()), + ); + assert.equal(swept.staleFailed, 1); + const row = await store.get(parked.id); + assert.equal(row?.status, 'failed'); + assert.match( + String(row?.error), + /no human answered/, + 'a parked expiry must not be reported as a crashed worker', + ); + }); + + it('MUTATION CHECK: the parked window is measured from the park, not the frozen heartbeat', async () => { + // `lastHeartbeatAt` is stamped at CLAIM time and never moves again once the + // task parks, so ageing a parked task by it charges the parked window for + // time the worker was still actively running. + const { store, advance, nowMs } = driven(1_000_000); + const t = await store.create({ kind: 'k', input: {} }); + const lease = randomUUID(); + await store.claimNextPending(lease, undefined, t.id); + const claimedAt = (await store.get(t.id))?.lastHeartbeatAt; + + // The worker runs for 50 minutes, THEN parks the task on a human. + advance(50 * 60_000); + await store.requireInput(t.id, lease, 'awaiting_human'); + assert.equal( + (await store.get(t.id))?.lastHeartbeatAt, + claimedAt, + 'precondition: parking does not move the heartbeat', + ); + + // 20 minutes later — 70 minutes since the claim, 20 since the park. + advance(20 * 60_000); + const swept = await runTaskReaperOnce( + store, + { parkedStaleAfterMs: 60 * 60_000 }, + new Date(nowMs()), + ); + assert.equal( + swept.staleFailed, + 0, + 'the human has had 20 of their 60 minutes, not 70', + ); + assert.equal((await store.get(t.id))?.status, 'input_required'); + }); +}); diff --git a/middleware/test/tasks/longRunningTool.test.ts b/middleware/test/tasks/longRunningTool.test.ts new file mode 100644 index 00000000..c3e300a3 --- /dev/null +++ b/middleware/test/tasks/longRunningTool.test.ts @@ -0,0 +1,499 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { + InMemoryTaskStore, + defineLongRunningTool, + describeDeferredPrivacyPosture, + longRunningToolNames, + runTaskReaperOnce, + type LongRunningToolHandle, + type TaskOutcomeLostRecord, +} from '@omadia/orchestrator'; + +/** + * W2-2 — the non-blocking contract: a long-running tool returns a HANDLE in + * milliseconds, never blocks the tool batch, and streams a card into the turn. + */ + +/** A never-resolving executor, so "did it block?" is decidable rather than a + * timing guess: if `_start` awaited the work, the await below would hang. */ +function pendingForever(): { promise: Promise; started: () => boolean } { + let began = false; + return { + promise: new Promise(() => { + began = true; + }), + started: () => began, + }; +} + +function buildTool( + execute: Parameters[0]['execute'], + store = new InMemoryTaskStore(), +): { handle: LongRunningToolHandle; store: InMemoryTaskStore } { + const handle = defineLongRunningTool({ + toolName: 'slow_thing', + longRunning: true, + kind: 'slow', + cardLabel: 'Slow Thing', + startDescription: 'Start the slow thing.', + inputProperties: { question: { type: 'string' } }, + requiredInput: ['question'], + store, + execute, + eventsUrlFor: (id) => `/api/tasks/${id}/events`, + onRunnerError: () => undefined, + }); + return { handle, store }; +} + +function reg(handle: LongRunningToolHandle, suffix: 'start' | 'status' | 'list') { + const name = longRunningToolNames('slow_thing')[suffix]; + const found = handle.registrations.find((r) => r.name === name); + assert.ok(found, `missing registration ${name}`); + return found; +} + +describe('tasks/defineLongRunningTool — names + registrations', () => { + it('registers exactly the start/status/list triple', () => { + const { handle } = buildTool(async () => 'x'); + assert.deepEqual( + handle.registrations.map((r) => r.name), + ['slow_thing_start', 'slow_thing_status', 'slow_thing_list'], + ); + }); + + it('refuses a base name that cannot fit the _status suffix in 64 chars', () => { + assert.throws(() => longRunningToolNames('a'.repeat(58)), TypeError); + assert.throws(() => longRunningToolNames('bad name!'), TypeError); + }); + + it("tells the model that _start does NOT return the answer", () => { + const { handle } = buildTool(async () => 'x'); + const doc = reg(handle, 'start').promptDoc; + assert.match(doc, /returns IMMEDIATELY/); + assert.match(doc, /does NOT return the answer/); + assert.match(doc, /Never wait in a loop/); + }); +}); + +describe('tasks/defineLongRunningTool — non-blocking', () => { + it('returns a handle without waiting for the work', async () => { + const pending = pendingForever(); + const { handle } = buildTool(async () => pending.promise); + + // If `_start` awaited `execute`, this await never resolves and the test + // times out — which is exactly the regression this pins. + const out = await reg(handle, 'start').handler({ question: 'q' }); + const parsed = JSON.parse(out) as Record; + + assert.equal(parsed['status'], 'task_started'); + assert.equal(parsed['tool'], 'slow_thing_start'); + assert.equal(parsed['kind'], 'slow'); + assert.equal(parsed['phase'], 'queued'); + assert.equal(typeof parsed['taskId'], 'string'); + // No result is present: the handle is not the answer. + assert.equal(parsed['result'], undefined); + }); + + it('does not block a parallel tool batch', async () => { + // Models dispatch tool_use blocks in one batch; a blocking long tool stalls + // every sibling in it. Two starts plus a fast sibling must all settle while + // the underlying work is still pending. + const pending = pendingForever(); + const { handle } = buildTool(async () => pending.promise); + const fast = async (): Promise => 'sibling-done'; + + const results = await Promise.all([ + reg(handle, 'start').handler({ question: 'a' }), + reg(handle, 'start').handler({ question: 'b' }), + fast(), + ]); + + assert.equal(results[2], 'sibling-done'); + const ids = results.slice(0, 2).map((r) => (JSON.parse(r) as { taskId: string }).taskId); + assert.equal(new Set(ids).size, 2, 'each start gets its own task'); + }); + + it('surfaces the result via _status once the work completes', async () => { + const { handle } = buildTool(async (input) => { + const q = (input as { question: string }).question; + return `answer to ${q}`; + }); + + const started = JSON.parse( + await reg(handle, 'start').handler({ question: 'life' }), + ) as { taskId: string }; + await handle.drainForTest(); + + const status = JSON.parse( + await reg(handle, 'status').handler({ taskId: started.taskId }), + ) as Record; + + assert.equal(status['status'], 'completed'); + assert.equal(status['terminal'], true); + assert.equal(status['result'], 'answer to life'); + }); + + it('records a thrown executor as a terminal failure, not a lost task', async () => { + const { handle } = buildTool(async () => { + throw new Error('backend exploded'); + }); + const started = JSON.parse( + await reg(handle, 'start').handler({ question: 'q' }), + ) as { taskId: string }; + await handle.drainForTest(); + + const status = JSON.parse( + await reg(handle, 'status').handler({ taskId: started.taskId }), + ) as Record; + assert.equal(status['status'], 'failed'); + assert.equal(status['error'], 'backend exploded'); + assert.equal(status['result'], undefined); + }); + + it('refuses bad input as an Error string instead of throwing', async () => { + const { handle } = buildTool(async () => 'x'); + assert.match(await reg(handle, 'start').handler('nope'), /^Error: /); + assert.match(await reg(handle, 'status').handler({}), /^Error: /); + assert.match( + await reg(handle, 'status').handler({ taskId: 'ghost' }), + /^Error: task "ghost" was not found/, + ); + }); + + it('lists only its own kind', async () => { + const store = new InMemoryTaskStore(); + await store.create({ kind: 'someone-elses-kind', input: {} }); + const { handle } = buildTool(async () => 'x', store); + await reg(handle, 'start').handler({ question: 'mine' }); + await handle.drainForTest(); + + const listed = JSON.parse(await reg(handle, 'list').handler({})) as unknown[]; + assert.equal(listed.length, 1); + assert.equal((listed[0] as { kind: string }).kind, 'slow'); + }); +}); + +describe('tasks/defineLongRunningTool — card streaming', () => { + it('queues one card per start and hands it to the turn exactly once', async () => { + const { handle } = buildTool(async () => 'x'); + assert.equal(handle.hasPendingCards(), false); + + await reg(handle, 'start').handler({ question: 'a' }); + await reg(handle, 'start').handler({ question: 'b' }); + assert.equal(handle.hasPendingCards(), true); + + const cards = handle.takePendingCards(); + assert.equal(cards.length, 2, 'a turn may start more than one task'); + assert.equal(cards[0]?.toolName, 'slow_thing_start'); + assert.equal(cards[0]?.label, 'Slow Thing'); + assert.equal(cards[0]?.status, 'working'); + assert.match(String(cards[0]?.eventsUrl), /^\/api\/tasks\/.+\/events$/); + + // Drained, not duplicated: a second drain in the same turn must be empty or + // the UI would render the card twice. + assert.deepEqual(handle.takePendingCards(), []); + assert.equal(handle.hasPendingCards(), false); + }); + + it('does not queue a card for a refused start', async () => { + const { handle } = buildTool(async () => 'x'); + await reg(handle, 'start').handler('garbage'); + assert.deepEqual(handle.takePendingCards(), []); + }); +}); + +describe('tasks/deferred-result privacy (criterion 6)', () => { + it('a card carries NO result and NO input — the shield boundary holds', async () => { + // Load-bearing. Cards are rendered client-side from the tool result / stream + // event and never pass through Orchestrator.dispatchTool, so anything + // sensitive placed on a card escapes the Privacy Shield data plane entirely. + const secret = 'IBAN DE89370400440532013000'; + const { handle } = buildTool(async () => secret); + + await reg(handle, 'start').handler({ question: `please handle ${secret}` }); + const cards = handle.takePendingCards(); + assert.equal(cards.length, 1); + + const card = cards[0]; + assert.ok(card); + const serialized = JSON.stringify(card); + assert.ok( + !serialized.includes(secret), + `card leaked sensitive content: ${serialized}`, + ); + // Positively pin the allowed key set, so a later field addition has to come + // back through this test rather than sneaking a payload onto the card. + assert.deepEqual(Object.keys(card).sort(), [ + 'eventsUrl', + 'kind', + 'label', + 'phase', + 'status', + 'taskId', + 'toolName', + ]); + }); + + it('a card is not updated with the result after the task completes', async () => { + const secret = 'sk-live-should-never-be-on-a-card'; + const { handle } = buildTool(async () => secret); + await reg(handle, 'start').handler({ question: 'q' }); + const cards = handle.takePendingCards(); + await handle.drainForTest(); + assert.ok(!JSON.stringify(cards).includes(secret)); + }); + + it('the result reaches the model ONLY through the _status tool result', async () => { + // That is what makes poll-time interning correct: `_status` is an ordinary + // tool call inside a live turn, so that turn's dispatchTool privacy pass + // applies to its return value in full. + const secret = 'deferred-secret-value'; + const { handle } = buildTool(async () => secret); + const started = JSON.parse( + await reg(handle, 'start').handler({ question: 'q' }), + ) as { taskId: string }; + + // Not on the start result. + assert.ok(!JSON.stringify(started).includes(secret)); + await handle.drainForTest(); + + const status = await reg(handle, 'status').handler({ taskId: started.taskId }); + assert.ok(status.includes(secret), 'the poll is the delivery channel'); + }); + + it('states the posture explicitly so it cannot drift silently', () => { + const posture = describeDeferredPrivacyPosture(); + assert.match(posture, /interned at POLL time/); + assert.match(posture, /cards carry no result or input/); + assert.match(posture, /v1 limitation/); + }); +}); + +/** + * A store whose `create` ACKNOWLEDGEMENT is reordered relative to the row write. + * + * Faithful model of any store with real I/O: the row (and its `createdAt`) is + * written when `create` is called, but the promise resolves after a variable + * round trip — so the order in which callers learn their task exists is NOT the + * order the rows were created in. `defineLongRunningTool` spawns a task's runner + * when its `create` resolves, which is exactly how a runner ends up starting for + * a task that is not the oldest unclaimed one. + * + * Everything else delegates to the real `InMemoryTaskStore`: the claim, lease + * and terminal semantics under test are the production ones. + */ +class AckReorderingTaskStore extends InMemoryTaskStore { + /** Extra microtask ticks before `create` resolves, keyed by input marker. */ + private readonly ackDelayTicks = new Map(); + + delayAckFor(marker: string, ticks: number): void { + this.ackDelayTicks.set(marker, ticks); + } + + override async create( + input: Parameters[0], + ): ReturnType { + // Row written NOW — `createdAt` ordering follows call order… + const descriptor = await super.create(input); + const marker = (input.input as { question?: string } | undefined)?.question; + const ticks = marker === undefined ? 0 : (this.ackDelayTicks.get(marker) ?? 0); + // …but the caller learns about it later, so runner-start order can differ. + for (let i = 0; i < ticks; i += 1) await Promise.resolve(); + return descriptor; + } +} + +describe('tasks/defineLongRunningTool — crossed claims (W4)', () => { + it('MUTATION CHECK: two same-kind tasks whose runners start out of order BOTH complete', async () => { + // The bug: `claimNextPending(lease, kind)` returns the OLDEST unclaimed task + // of that kind, not the one this runner was spawned for. With task A created + // first but B's runner starting first, B's runner claimed A, saw the id + // mismatch and returned WITHOUT releasing the claim; A's runner then claimed + // B and did the same. Both tasks sat `working` under live-but-dead leases + // with no executor at all until the orphan reaper failed them 15 minutes + // later — so a user who asked two questions got two answers that never came. + const store = new AckReorderingTaskStore(); + // A is created first (older `createdAt`) but acknowledged last, so its + // runner starts second and B's runner is the one that reaches the pool head. + store.delayAckFor('question-A', 4); + store.delayAckFor('question-B', 0); + + const seen: string[] = []; + const { handle } = buildTool(async (input) => { + const q = (input as { question: string }).question; + seen.push(q); + return `answer for ${q}`; + }, store); + + const [startedA, startedB] = await Promise.all([ + reg(handle, 'start').handler({ question: 'question-A' }), + reg(handle, 'start').handler({ question: 'question-B' }), + ]); + const idA = (JSON.parse(startedA) as { taskId: string }).taskId; + const idB = (JSON.parse(startedB) as { taskId: string }).taskId; + assert.notEqual(idA, idB); + + await handle.drainForTest(); + + // Both executed, exactly once each. + assert.deepEqual([...seen].sort(), ['question-A', 'question-B']); + + // Both reached a terminal state carrying THEIR OWN answer — the property + // that fails when a claim is crossed: under the old code both rows stayed + // `working` with no result at all. + const statusA = JSON.parse( + await reg(handle, 'status').handler({ taskId: idA }), + ) as Record; + const statusB = JSON.parse( + await reg(handle, 'status').handler({ taskId: idB }), + ) as Record; + + assert.equal(statusA['status'], 'completed', 'task A must not be stranded'); + assert.equal(statusB['status'], 'completed', 'task B must not be stranded'); + assert.equal(statusA['result'], 'answer for question-A'); + assert.equal(statusB['result'], 'answer for question-B'); + + // And no row is left holding a lease. + for (const id of [idA, idB]) { + const row = await store.get(id); + assert.ok(row); + assert.equal(row.claimedBy, null, `task ${id} still holds a lease`); + } + }); + + it('MUTATION CHECK: a claim this runner cannot hand back is finished, never abandoned', async () => { + // Defence in depth for a store that CANNOT honour the claim hint — the seam + // permits exactly that, e.g. a store whose claim is a bare pool pop with no + // id predicate and no release primitive. The rule the runner must follow is + // "whatever you claimed, you finish": walking away from a claim is what + // strands a task, regardless of WHY the ids differ. + const store = new InMemoryTaskStore(); + const hintIgnoring = Object.create(store) as InMemoryTaskStore; + Object.defineProperty(hintIgnoring, 'claimNextPending', { + value: (lease: string, kind?: string) => + InMemoryTaskStore.prototype.claimNextPending.call(store, lease, kind), + }); + + // A pre-existing unclaimed task of the same kind, older than anything the + // tool creates — so the pool head is never the task the runner asks for. + const decoy = await store.create({ kind: 'slow', input: { question: 'decoy' } }); + + const { handle } = buildTool(async (input) => { + const q = (input as { question: string }).question; + return `answer for ${q}`; + }, hintIgnoring); + + await reg(handle, 'start').handler({ question: 'mine' }); + await handle.drainForTest(); + + // The runner was spawned for the new task and handed `decoy`. It must have + // executed and finished the DECOY rather than dropping it: the decoy is the + // row it holds the lease on. + const decoyRow = await store.get(decoy.id); + assert.ok(decoyRow); + assert.equal( + decoyRow.status, + 'completed', + 'the claimed task was abandoned under a live lease', + ); + assert.equal(decoyRow.result, 'answer for decoy'); + assert.equal(decoyRow.claimedBy, null, 'the lease was never released'); + }); +}); + +describe('tasks/defineLongRunningTool — outcome lost to a reaped lease (W4)', () => { + /** Drive a runner that finishes AFTER the reaper already failed its task. */ + async function reapMidFlight(executorOutcome: 'succeed' | 'throw'): Promise<{ + lost: TaskOutcomeLostRecord[]; + runnerErrors: unknown[]; + taskId: string; + store: InMemoryTaskStore; + }> { + let clock = 1_000_000; + const store = new InMemoryTaskStore({ clock: () => clock }); + const lost: TaskOutcomeLostRecord[] = []; + const runnerErrors: unknown[] = []; + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + + const handle = defineLongRunningTool({ + toolName: 'slow_thing', + longRunning: true, + kind: 'slow', + cardLabel: 'Slow Thing', + startDescription: 'Start the slow thing.', + inputProperties: { question: { type: 'string' } }, + store, + execute: async () => { + await gate; + if (executorOutcome === 'throw') throw new Error('backend exploded'); + return 'THE REAL ANSWER'; + }, + onRunnerError: (err) => runnerErrors.push(err), + onOutcomeLost: (record) => lost.push(record), + }); + + const started = JSON.parse( + await reg(handle, 'start').handler({ question: 'q' }), + ) as { taskId: string }; + // Let the runner claim and enter `execute`. + await Promise.resolve(); + await Promise.resolve(); + + // The reaper decides the worker is dead and writes its own terminal row. + clock += 20 * 60_000; + const swept = await runTaskReaperOnce(store, { staleAfterMs: 15 * 60_000 }); + assert.equal(swept.staleFailed, 1, 'the reaper must have force-failed the task'); + + release(); + await handle.drainForTest(); + return { lost, runnerErrors, taskId: started.taskId, store }; + } + + it('MUTATION CHECK: a SUCCESSFUL result is surfaced, not swallowed as a runner error', async () => { + // The bug: `finish(…, 'completed')` threw `TaskLeaseLostError`, the generic + // `catch` then called `finish(…, 'failed')` on the now-terminal row, that + // threw AGAIN and escaped to `onRunnerError`. So a task that genuinely + // SUCCEEDED left no trace of its result anywhere, and the caller saw the + // reaper's generic "task abandoned" as if nothing had ever run. + const { lost, runnerErrors, taskId } = await reapMidFlight('succeed'); + + assert.deepEqual(runnerErrors, [], 'lease loss is not a runner error'); + assert.equal(lost.length, 1, 'the lost outcome must be reported exactly once'); + const record = lost[0]; + assert.ok(record); + assert.equal(record.taskId, taskId); + assert.equal(record.status, 'completed'); + assert.equal( + record.result, + 'THE REAL ANSWER', + 'the real result must be preserved, not replaced by a generic abandonment', + ); + assert.equal(record.error, undefined); + }); + + it('MUTATION CHECK: a FAILED outcome hitting the same race is reported, never re-thrown', async () => { + const { lost, runnerErrors } = await reapMidFlight('throw'); + assert.deepEqual(runnerErrors, []); + assert.equal(lost.length, 1); + assert.equal(lost[0]?.status, 'failed'); + assert.equal(lost[0]?.error, 'backend exploded'); + }); + + it('the stored row still reflects the reaper — terminal immutability is not relaxed', async () => { + // Stated rather than implied: the outcome is surfaced through the hook, NOT + // by overwriting a terminal row. That guard is what stops a zombie worker + // from overwriting an outcome a new owner recorded, so it stays. + const { store, taskId } = await reapMidFlight('succeed'); + const row = await store.get(taskId); + assert.ok(row); + assert.equal(row.status, 'failed'); + assert.match(String(row.error), /task abandoned/); + assert.equal(row.result, null); + }); +}); diff --git a/middleware/test/tasks/subAgentTaskTool.test.ts b/middleware/test/tasks/subAgentTaskTool.test.ts new file mode 100644 index 00000000..20332408 --- /dev/null +++ b/middleware/test/tasks/subAgentTaskTool.test.ts @@ -0,0 +1,351 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { + ContentPart, + LlmProvider, + LlmRequest, + LlmResponse, + LlmStreamEvent, +} from '@omadia/llm-provider'; +import { + InMemoryTaskStore, + LocalSubAgent, + createDomainTool, + createLongRunningSubAgentTool, + longRunningToolNames, +} from '@omadia/orchestrator'; + +import { registerDbSubAgentTools } from '../../src/agents/subAgentToolHydration.js'; + +/** + * W2-2 criterion 4 — the SECOND consumer, proving the seam is genuinely general. + * + * This drives a REAL `LocalSubAgent` (the thing `registry/subAgentTools.ts` + * builds) through the generic seam and asserts the parent turn stops blocking on + * it. Not a stub of the seam and not a stub of the sub-agent: only the LLM wire + * is stubbed, exactly as `test/orchestrator/localSubAgent.test.ts` does. + */ + +const CAPS = { + tools: true, + vision: false, + streaming: false, + promptCaching: false, + forcedToolChoice: false, + parallelToolCalls: false, +} as const; + +function llmResponse(answer: string): LlmResponse { + const content: ContentPart[] = [{ type: 'text', text: answer }]; + return { + content, + finishReason: 'stop', + providerFinishReason: 'end_turn', + model: 'stub-model', + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }; +} + +/** + * A provider whose single reply is gated on an external latch, so "did the + * parent turn block?" is decidable rather than a timing guess. + * + * `LocalSubAgent.ask` goes through `streamMessageWithObserver`, so the stub must + * provide `stream` + `classifyError` (mirrors `test/orchestrator/localSubAgent.test.ts`). + */ +function latchedProvider(answer: string): { + provider: LlmProvider; + release: () => void; + calls: () => number; +} { + let releaseFn: () => void = () => undefined; + const gate = new Promise((res) => { + releaseFn = res; + }); + let calls = 0; + const provider = { + id: 'anthropic', + capabilities: CAPS, + complete: async (_req: LlmRequest): Promise => { + calls += 1; + await gate; + return llmResponse(answer); + }, + stream: (_req: LlmRequest): AsyncIterable => { + calls += 1; + return { + async *[Symbol.asyncIterator]() { + await gate; + yield { type: 'text_delta', text: answer } as LlmStreamEvent; + yield { type: 'final', response: llmResponse(answer) } as LlmStreamEvent; + }, + }; + }, + classifyError: () => ({ retryable: false, kind: 'other' as const }), + } as unknown as LlmProvider; + return { provider, release: () => releaseFn(), calls: () => calls }; +} + +function realSubAgent(provider: LlmProvider): LocalSubAgent { + return new LocalSubAgent({ + name: 'Research', + provider, + model: 'stub-model', + maxTokens: 256, + maxIterations: 2, + systemPrompt: 'You are a research sub-agent.', + tools: [], + }); +} + +describe('tasks/createLongRunningSubAgentTool — real LocalSubAgent, deferred', () => { + it('returns a handle while the sub-agent is still mid-LLM-call', async () => { + const { provider, release, calls } = latchedProvider('the deferred answer'); + const handle = createLongRunningSubAgentTool({ + baseToolName: 'ask_research', + displayName: 'Research', + description: 'Answers research questions.', + agent: realSubAgent(provider), + store: new InMemoryTaskStore(), + onRunnerError: () => undefined, + }); + + const names = longRunningToolNames('ask_research'); + const start = handle.registrations.find((r) => r.name === names.start); + const status = handle.registrations.find((r) => r.name === names.status); + assert.ok(start && status); + + // The parent turn's tool dispatch. If the seam awaited the sub-agent's LLM + // loop, this would hang on the latch — which is the whole regression. + const startOut = JSON.parse( + await start.handler({ question: 'what is the state of the art?' }), + ) as { status: string; taskId: string }; + assert.equal(startOut.status, 'task_started'); + + // The sub-agent is genuinely running, and genuinely not finished. + const mid = JSON.parse(await status.handler({ taskId: startOut.taskId })) as { + status: string; + terminal: boolean; + result?: string; + }; + assert.equal(mid.status, 'working'); + assert.equal(mid.terminal, false); + assert.equal(mid.result, undefined, 'no answer yet — this is the deferred shape'); + + // Let the sub-agent finish, then collect on a LATER poll (i.e. a later turn). + release(); + await handle.drainForTest(); + const done = JSON.parse(await status.handler({ taskId: startOut.taskId })) as { + status: string; + result: string; + }; + assert.equal(done.status, 'completed'); + assert.equal(done.result, 'the deferred answer'); + assert.equal(calls(), 1, 'the sub-agent ran exactly once'); + }); + + it('reports a sub-agent failure as a terminal task, not a hung handle', async () => { + const failing = { + ask: async (): Promise => { + throw new Error('sub-agent provider 503'); + }, + }; + const handle = createLongRunningSubAgentTool({ + baseToolName: 'ask_flaky', + displayName: 'Flaky', + description: 'Flaky.', + agent: failing, + store: new InMemoryTaskStore(), + onRunnerError: () => undefined, + }); + const names = longRunningToolNames('ask_flaky'); + const start = handle.registrations.find((r) => r.name === names.start); + const status = handle.registrations.find((r) => r.name === names.status); + assert.ok(start && status); + + const started = JSON.parse(await start.handler({ question: 'q' })) as { taskId: string }; + await handle.drainForTest(); + const out = JSON.parse(await status.handler({ taskId: started.taskId })) as { + status: string; + error: string; + }; + assert.equal(out.status, 'failed'); + assert.match(out.error, /503/); + }); + + it('refuses an empty question before creating a task', async () => { + const store = new InMemoryTaskStore(); + const handle = createLongRunningSubAgentTool({ + baseToolName: 'ask_x', + displayName: 'X', + description: 'X.', + agent: { ask: async (): Promise => 'never' }, + store, + }); + const start = handle.registrations.find((r) => r.name === 'ask_x_start'); + assert.ok(start); + assert.match(await start.handler({ question: ' ' }), /^Error: /); + assert.equal(store.size(), 0, 'a refused start must not leave a task row'); + }); +}); + +describe('tasks/boot wiring — registerDbSubAgentTools opt-in', () => { + const subAgentRow = { + id: 's1', + agentId: 'a1', + name: 'Research', + slug: 'research', + status: 'enabled', + model: null, + maxTokens: null, + maxIterations: null, + skillId: null, + systemPromptOverride: 'Research things.', + description: 'Research sub-agent.', + }; + + function makeHost(): { registered: string[]; host: Parameters[1] } { + const registered: string[] = []; + return { + registered, + host: { + orchestrator: { + hasDomainTool: (n: string) => registered.includes(n), + registerDomainTool: (t: { name: string }) => registered.push(t.name), + }, + } as Parameters[1], + }; + } + + function makeDeps( + nativeNames: string[], + extra: Partial[2]> = {}, + ): Parameters[2] { + return { + client: {} as Parameters[2]['client'], + nativeToolRegistry: { + get: (n: string) => (nativeNames.includes(n) ? {} : undefined), + register: (n: string) => { + nativeNames.push(n); + return () => undefined; + }, + } as unknown as Parameters[2]['nativeToolRegistry'], + mcpManager: {} as Parameters[2]['mcpManager'], + mcpServers: [], + defaultModel: 'stub-model', + ...extra, + }; + } + + const slice = { + subAgents: [subAgentRow], + toolGrants: [], + skills: [], + } as unknown as Parameters[0]; + + it('registers ONLY the blocking tool when nothing is opted in', () => { + const native: string[] = []; + const { host, registered } = makeHost(); + registerDbSubAgentTools(slice, host, makeDeps(native)); + assert.deepEqual(registered, ['ask_research']); + assert.deepEqual(native, [], 'no deferred triple without an allowlist'); + }); + + it('adds the deferred triple ALONGSIDE the blocking tool when opted in', () => { + const native: string[] = []; + const { host, registered } = makeHost(); + registerDbSubAgentTools( + slice, + host, + makeDeps(native, { + longRunningSubAgentTools: ['ask_research'], + taskStore: new InMemoryTaskStore(), + }), + ); + // The blocking tool is untouched — a fast sub-agent keeps answering inline. + assert.deepEqual(registered, ['ask_research']); + assert.deepEqual(native, [ + 'ask_research_start', + 'ask_research_status', + 'ask_research_list', + ]); + }); + + it('ignores an allowlist entry naming a sub-agent that does not exist', () => { + const native: string[] = []; + const { host } = makeHost(); + registerDbSubAgentTools( + slice, + host, + makeDeps(native, { + longRunningSubAgentTools: ['ask_nonexistent'], + taskStore: new InMemoryTaskStore(), + }), + ); + assert.deepEqual(native, []); + }); + + it('is off when an allowlist is given but no store is', () => { + const native: string[] = []; + const { host } = makeHost(); + registerDbSubAgentTools( + slice, + host, + makeDeps(native, { longRunningSubAgentTools: ['ask_research'] }), + ); + assert.deepEqual(native, [], 'fail closed: no store ⇒ no deferred tools'); + }); + + it('does not double-register on a repeated hydrate', () => { + const native: string[] = []; + const deps = makeDeps(native, { + longRunningSubAgentTools: ['ask_research'], + taskStore: new InMemoryTaskStore(), + }); + registerDbSubAgentTools(slice, makeHost().host, deps); + registerDbSubAgentTools(slice, makeHost().host, deps); + assert.equal(native.length, 3, 'the second hydrate skips existing names'); + }); +}); + +describe('tasks/DomainTool→Askable adaptation', () => { + it('routes the deferred call through the SAME DomainTool handle', async () => { + // The adaptation must not bypass the DomainTool: its error wrapping and + // domain logging are the behaviour the inline path already has, so reusing it + // is what keeps the deferred and inline routes from drifting apart. + const asked: string[] = []; + const domainTool = createDomainTool({ + name: 'ask_probe', + description: 'Probe.', + domain: 'subagent.probe', + agent: { + ask: async (q: string): Promise => { + asked.push(q); + return `answered: ${q}`; + }, + }, + }); + + const handle = createLongRunningSubAgentTool({ + baseToolName: domainTool.name, + displayName: 'Probe', + description: domainTool.spec.description, + agent: { ask: (q, obs) => domainTool.handle({ question: q }, obs) }, + store: new InMemoryTaskStore(), + }); + const start = handle.registrations.find((r) => r.name === 'ask_probe_start'); + const status = handle.registrations.find((r) => r.name === 'ask_probe_status'); + assert.ok(start && status); + + const started = JSON.parse(await start.handler({ question: 'ping' })) as { + taskId: string; + }; + await handle.drainForTest(); + const out = JSON.parse(await status.handler({ taskId: started.taskId })) as { + result: string; + }; + assert.deepEqual(asked, ['ping'], 'the wrapped agent saw the question verbatim'); + assert.equal(out.result, 'answered: ping'); + }); +}); diff --git a/middleware/test/toolIdempotency.test.ts b/middleware/test/toolIdempotency.test.ts new file mode 100644 index 00000000..3e94fd60 --- /dev/null +++ b/middleware/test/toolIdempotency.test.ts @@ -0,0 +1,532 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { NativeToolRegistry } from '../packages/harness-orchestrator/src/nativeToolRegistry.js'; +import { ToolDispatchService } from '../packages/harness-orchestrator/src/toolDispatchService.js'; +import { + ToolIdempotencyStore, + currentIdempotencyScope, + fingerprintToolInput, + idempotencyCacheKey, +} from '../packages/harness-orchestrator/src/toolIdempotency.js'; +import type { WriteCapability } from '../packages/plugin-api/src/writeCapabilities.js'; +import { isWriteCapableTool } from '../packages/plugin-api/src/writeCapabilities.js'; + +/** + * #542 prerequisite — idempotency for write-capable tool dispatch. + * + * MUTATION-CHECK DISCIPLINE: the assertions count REAL EXECUTIONS of the + * underlying handler (a counter the handler itself increments), never mock + * invocation counts on a dedupe helper. A test that asserted "the store was + * consulted" would stay green over a store that always misses. + */ + +const CREATE_INVOICE: readonly WriteCapability[] = [ + { dataClass: 'odoo.invoice', operation: 'create' }, +]; + +/** A write-capable tool whose handler counts how many times it really ran. */ +function writeToolService(options?: { + readonly capabilities?: readonly WriteCapability[]; + readonly store?: ToolIdempotencyStore; + readonly failWith?: () => never; +}): { service: ToolDispatchService; executions: () => number } { + let executions = 0; + const nativeTools = new NativeToolRegistry(); + nativeTools.register('odoo_create_invoice', { + handler: async (input) => { + executions += 1; + options?.failWith?.(); + return `invoice-created:${JSON.stringify(input)}`; + }, + spec: { + name: 'odoo_create_invoice', + description: 'creates an invoice — mutates data', + input_schema: { type: 'object', properties: {} }, + }, + domain: 'test.odoo', + ...(options?.capabilities !== undefined + ? { writeCapabilities: options.capabilities } + : {}), + }); + return { + service: new ToolDispatchService({ + nativeTools, + domainTools: [], + ...(options?.store !== undefined ? { idempotency: options.store } : {}), + }), + executions: () => executions, + }; +} + +describe('write-capability declaration', () => { + it('treats a tool with declared write capabilities as write-capable', () => { + assert.equal(isWriteCapableTool(CREATE_INVOICE), true); + }); + + it('treats an unannotated or empty declaration as read-only', () => { + assert.equal(isWriteCapableTool(undefined), false); + assert.equal(isWriteCapableTool([]), false); + }); + + it('surfaces the declaration through the registry onto the dispatcher', () => { + const { service } = writeToolService({ capabilities: CREATE_INVOICE }); + assert.equal(service.isWriteCapable('odoo_create_invoice'), true); + assert.equal(service.isWriteCapable('nope'), false); + }); + + it('reaches the dispatcher through the PLUGIN-facing accessor, not just kernel calls', async () => { + // The declaration is worthless if a real plugin cannot make it. This walks + // the actual `ctx.tools.register(spec, handler, options)` shim the kernel + // gives plugins and asserts the capability survives the hop into the + // registry — a shim that silently drops the field would leave every real + // Odoo/M365 write unprotected while all the unit tests stayed green. + const { createPluginContext } = await import('../src/platform/pluginContext.js'); + const { ServiceRegistry } = await import('../src/platform/serviceRegistry.js'); + type Opts = Parameters[0]; + const stub = (): (() => void) => (): void => {}; + const nativeTools = new NativeToolRegistry(); + const ctx = createPluginContext({ + agentId: '@omadia/integration-odoo', + vault: { + get: async () => undefined, + listKeys: async () => [], + } as unknown as Opts['vault'], + registry: { + has: () => true, + list: () => [], + get: () => undefined, + } as unknown as Opts['registry'], + catalog: new Map() as unknown as Opts['catalog'], + serviceRegistry: new ServiceRegistry(), + nativeToolRegistry: nativeTools, + routeRegistry: { + register: stub, + disposeBySource: () => 0, + } as unknown as Opts['routeRegistry'], + jobScheduler: { + register: stub, + stopForPlugin: () => {}, + } as unknown as Opts['jobScheduler'], + logger: () => {}, + }); + + ctx.tools.register( + { + name: 'odoo_post_invoice', + description: 'posts an invoice', + input_schema: { type: 'object', properties: {} }, + }, + async () => 'posted', + { writeCapabilities: CREATE_INVOICE }, + ); + + assert.deepEqual( + nativeTools.get('odoo_post_invoice')?.writeCapabilities, + CREATE_INVOICE, + 'the plugin-facing shim dropped writeCapabilities', + ); + const service = new ToolDispatchService({ nativeTools, domainTools: [] }); + assert.equal(service.isWriteCapable('odoo_post_invoice'), true); + }); +}); + +describe('ToolIdempotencyStore', () => { + it('executes once and REPLAYS the stored result for a duplicate key', async () => { + const store = new ToolIdempotencyStore(); + let runs = 0; + const exec = async () => { + runs += 1; + return { content: `run-${String(runs)}` }; + }; + + const a = await store.run('k1', 'tool', { x: 1 }, exec); + const b = await store.run('k1', 'tool', { x: 1 }, exec); + + assert.equal(runs, 1, 'the executor must run exactly once'); + assert.equal(a.result.content, 'run-1'); + assert.equal(b.result.content, 'run-1', 'the duplicate must see the FIRST result'); + assert.equal(b.replayed, true); + }); + + it('COLLAPSES concurrent duplicates onto one execution', async () => { + const store = new ToolIdempotencyStore(); + let runs = 0; + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const exec = async () => { + runs += 1; + await gate; + return { content: 'once' }; + }; + + const both = Promise.all([ + store.run('k1', 'tool', { x: 1 }, exec), + store.run('k1', 'tool', { x: 1 }, exec), + ]); + release?.(); + const [a, b] = await both; + + assert.equal(runs, 1, 'a concurrent duplicate must not start a second execution'); + assert.equal(a.result.content, 'once'); + assert.equal(b.result.content, 'once'); + }); + + it('REJECTS a reused key carrying a different payload instead of executing', async () => { + const store = new ToolIdempotencyStore(); + let runs = 0; + const exec = async () => { + runs += 1; + return { content: 'ok' }; + }; + + await store.run('k1', 'tool', { amount: 100 }, exec); + const conflict = await store.run('k1', 'tool', { amount: 999 }, exec); + + assert.equal(runs, 1, 'a conflicting payload must NOT execute'); + assert.equal(conflict.result.isError, true); + assert.match(conflict.result.content, /idempotency key reused/); + }); + + it('treats key-equal payloads with reordered object keys as the SAME call', async () => { + assert.equal( + fingerprintToolInput({ a: 1, b: 2 }), + fingerprintToolInput({ b: 2, a: 1 }), + 'key order must not change the fingerprint, or a benign re-serialisation looks like a conflict', + ); + const store = new ToolIdempotencyStore(); + let runs = 0; + const exec = async () => { + runs += 1; + return { content: 'ok' }; + }; + await store.run('k1', 'tool', { a: 1, b: 2 }, exec); + await store.run('k1', 'tool', { b: 2, a: 1 }, exec); + assert.equal(runs, 1); + }); + + it('re-executes after the TTL window expires (bounded, not permanent)', async () => { + let now = 1_000; + const store = new ToolIdempotencyStore({ ttlMs: 500, now: () => now }); + let runs = 0; + const exec = async () => { + runs += 1; + return { content: `run-${String(runs)}` }; + }; + + await store.run('k1', 'tool', {}, exec); + now += 499; + await store.run('k1', 'tool', {}, exec); + assert.equal(runs, 1, 'still inside the window — must replay'); + + now += 2; + const after = await store.run('k1', 'tool', {}, exec); + assert.equal(runs, 2, 'past the window — must execute again'); + assert.equal(after.result.content, 'run-2'); + }); + + it('does NOT retain an isError outcome, so a caller may legitimately retry', async () => { + const store = new ToolIdempotencyStore(); + let runs = 0; + const exec = async () => { + runs += 1; + return runs === 1 + ? { content: 'Error: downstream refused', isError: true } + : { content: 'ok' }; + }; + + const first = await store.run('k1', 'tool', {}, exec); + assert.equal(first.result.isError, true); + const second = await store.run('k1', 'tool', {}, exec); + + assert.equal(runs, 2, 'a failed call must not be cached as the final answer'); + assert.equal(second.result.content, 'ok'); + }); + + it('does NOT retain a thrown outcome', async () => { + const store = new ToolIdempotencyStore(); + let runs = 0; + const exec = async (): Promise<{ content: string }> => { + runs += 1; + if (runs === 1) throw new Error('boom'); + return { content: 'ok' }; + }; + + await assert.rejects(() => store.run('k1', 'tool', {}, exec), /boom/); + const second = await store.run('k1', 'tool', {}, exec); + + assert.equal(runs, 2); + assert.equal(second.result.content, 'ok'); + assert.equal(store.size(), 1, 'the rejected entry must not linger alongside the good one'); + }); + + it('does not let a key containing the separator collide with another tool', async () => { + // `("a:b", "t")` and `("b", "t:a")` must stay distinct. A naive + // `${toolName}:${key}` composition maps BOTH to `t:a:b`, which would let one + // caller's key replay another tool's stored write result. + assert.notEqual( + idempotencyCacheKey('a:b', 't'), + idempotencyCacheKey('b', 't:a'), + 'cache-key composition is ambiguous — one tool could replay another tool result', + ); + + const store = new ToolIdempotencyStore(); + let runs = 0; + const exec = async () => { + runs += 1; + return { content: `run-${String(runs)}` }; + }; + const a = await store.run('a:b', 't', {}, exec); + const b = await store.run('b', 't:a', {}, exec); + + assert.equal(runs, 2, 'two distinct (key, tool) pairs must both execute'); + assert.equal(a.result.content, 'run-1'); + assert.equal(b.result.content, 'run-2'); + }); + + it('bounds retained records', async () => { + const store = new ToolIdempotencyStore({ maxEntries: 3 }); + for (let i = 0; i < 10; i += 1) { + await store.run(`k${String(i)}`, 'tool', {}, async () => ({ content: 'ok' })); + } + assert.equal(store.size(), 3); + }); +}); + +describe('ToolDispatchService — idempotent write dispatch', () => { + it('executes a write tool ONCE across duplicate dispatches sharing a key', async () => { + const store = new ToolIdempotencyStore(); + const { service, executions } = writeToolService({ + capabilities: CREATE_INVOICE, + store, + }); + + const a = await service.dispatch( + 'odoo_create_invoice', + { amount: 100 }, + { idempotencyKey: 'req-1' }, + ); + const b = await service.dispatch( + 'odoo_create_invoice', + { amount: 100 }, + { idempotencyKey: 'req-1' }, + ); + + assert.equal(executions(), 1, 'the write executed twice — duplicate customer data'); + assert.equal(a.content, b.content); + }); + + it('executes a write tool TWICE under DIFFERENT keys (dedupe is per key, not per tool)', async () => { + const store = new ToolIdempotencyStore(); + const { service, executions } = writeToolService({ + capabilities: CREATE_INVOICE, + store, + }); + + await service.dispatch('odoo_create_invoice', { amount: 1 }, { idempotencyKey: 'req-1' }); + await service.dispatch('odoo_create_invoice', { amount: 2 }, { idempotencyKey: 'req-2' }); + + assert.equal(executions(), 2, 'two distinct requests must both run'); + }); + + it('does NOT dedupe a READ tool — a cached read would serve stale data', async () => { + const store = new ToolIdempotencyStore(); + // Same tool, no write-capability declaration ⇒ read-only. + const { service, executions } = writeToolService({ store }); + + await service.dispatch('odoo_create_invoice', {}, { idempotencyKey: 'req-1' }); + await service.dispatch('odoo_create_invoice', {}, { idempotencyKey: 'req-1' }); + + assert.equal(executions(), 2, 'a read tool must not be deduplicated'); + }); + + it('is INERT without a store (legacy behaviour preserved)', async () => { + const { service, executions } = writeToolService({ capabilities: CREATE_INVOICE }); + + await service.dispatch('odoo_create_invoice', {}, { idempotencyKey: 'req-1' }); + await service.dispatch('odoo_create_invoice', {}, { idempotencyKey: 'req-1' }); + + assert.equal(executions(), 2); + }); + + it('publishes an exactlyOnce scope to layers beneath the handler — for writes only', async () => { + const store = new ToolIdempotencyStore(); + const seen: Array<{ key: string; exactlyOnce: boolean } | undefined> = []; + const nativeTools = new NativeToolRegistry(); + const record = async (): Promise => { + const scope = currentIdempotencyScope(); + seen.push( + scope === undefined + ? undefined + : { key: scope.key, exactlyOnce: scope.exactlyOnce }, + ); + return 'ok'; + }; + nativeTools.register('write_tool', { + handler: record, + spec: { + name: 'write_tool', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + domain: 'test.x', + writeCapabilities: CREATE_INVOICE, + }); + nativeTools.register('read_tool', { + handler: record, + spec: { + name: 'read_tool', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + domain: 'test.x', + }); + const service = new ToolDispatchService({ + nativeTools, + domainTools: [], + idempotency: store, + }); + + await service.dispatch('write_tool', {}, { idempotencyKey: 'req-1' }); + await service.dispatch('read_tool', {}, { idempotencyKey: 'req-2' }); + + assert.deepEqual(seen, [{ key: 'req-1', exactlyOnce: true }, undefined]); + }); +}); + +/** + * W4 — an in-flight entry used to be exempt from BOTH expiry and eviction, with + * no upper bound at all. The reasoning ("it never expires out from under its own + * execution") holds only while the execution finishes. A handler that hangs + * forever — the exact failure the dispatch deadline exists for, and the deadline + * resolves the SLOT, it does not make the underlying promise settle — pinned its + * key permanently: every later call under that key awaited a promise that never + * resolved, and the entry could not be evicted, so the map grew past + * `maxEntries` unchecked. + */ +describe('ToolIdempotencyStore — in-flight entries are bounded (W4)', () => { + /** A promise that never settles: a hung executor, not a slow one. */ + function hung(): Promise<{ content: string }> { + return new Promise<{ content: string }>(() => undefined); + } + + it('MUTATION CHECK: a hung execution stops pinning its key once the TTL passes', async () => { + let clock = 0; + const store = new ToolIdempotencyStore({ ttlMs: 1_000, now: () => clock }); + + // Fire and DO NOT await — this one never settles. + void store.run('k1', 'write_tool', { a: 1 }, hung).catch(() => undefined); + + clock += 2_000; + + // Under the bug this call collapsed onto the hung promise and never + // resolved, so the test would time out rather than fail — which is why the + // assertion below is guarded by an explicit race instead of a bare await. + let executed = 0; + const fresh = store.run('k1', 'write_tool', { a: 1 }, async () => { + executed += 1; + return { content: 'fresh result' }; + }); + const settled = await Promise.race([ + fresh, + new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 250)), + ]); + + assert.notEqual(settled, 'hung', 'the expired in-flight entry still pinned the key'); + assert.equal(executed, 1, 'the replacement execution must actually run'); + assert.deepEqual( + (settled as Awaited).result, + { content: 'fresh result' }, + ); + assert.equal((settled as Awaited).replayed, false); + }); + + it('collapses a concurrent duplicate onto a still-live in-flight execution', async () => { + // The property the expiry must NOT break: inside the TTL, duplicates share + // one execution instead of racing. + let clock = 0; + const store = new ToolIdempotencyStore({ ttlMs: 10_000, now: () => clock }); + let executed = 0; + let release!: (value: { content: string }) => void; + const gate = new Promise<{ content: string }>((r) => { + release = r; + }); + + const first = store.run('k1', 'write_tool', { a: 1 }, () => { + executed += 1; + return gate; + }); + clock += 500; + const second = store.run('k1', 'write_tool', { a: 1 }, () => { + executed += 1; + return gate; + }); + + release({ content: 'once' }); + const [a, b] = await Promise.all([first, second]); + + assert.equal(executed, 1, 'the duplicate must not get its own execution'); + assert.equal(a.replayed, false); + assert.equal(b.replayed, true); + assert.deepEqual(b.result, { content: 'once' }); + }); + + it('MUTATION CHECK: hung executions cannot grow the map without bound', async () => { + let clock = 0; + const store = new ToolIdempotencyStore({ + ttlMs: 1_000, + maxEntries: 2, + now: () => clock, + }); + + for (let i = 0; i < 5; i += 1) { + void store.run(`k${String(i)}`, 'write_tool', { i }, hung).catch(() => undefined); + } + // While genuinely live, in-flight entries are correctly protected from + // eviction — losing one would break duplicate collapsing. + assert.equal(store.size(), 5); + + clock += 2_000; + + // One more dispatch. Its own entry is live; the five stale ones are not, and + // the evictor now runs on the in-flight path too (it used to run only after + // a SUCCESSFUL completion, so a burst of hung calls never triggered it). + void store.run('k-new', 'write_tool', { n: 1 }, hung).catch(() => undefined); + + assert.ok( + store.size() <= 2, + `stale in-flight entries were never evicted — map holds ${String(store.size())} entries with maxEntries=2`, + ); + }); + + it('a late completion does not clobber a newer execution installed under the same key', async () => { + // Direct consequence of making in-flight entries expirable: two executions + // can legitimately exist for one key. The older one's outcome must not + // overwrite the newer one's entry. + let clock = 0; + const store = new ToolIdempotencyStore({ ttlMs: 1_000, now: () => clock }); + let releaseOld!: (value: { content: string }) => void; + const old = new Promise<{ content: string }>((r) => { + releaseOld = r; + }); + + const first = store.run('k1', 'write_tool', { a: 1 }, () => old); + clock += 2_000; + const second = await store.run('k1', 'write_tool', { a: 1 }, async () => ({ + content: 'newer', + })); + assert.deepEqual(second.result, { content: 'newer' }); + + releaseOld({ content: 'stale' }); + await first; + + // The newer result is what a replay gets. + const replay = await store.run('k1', 'write_tool', { a: 1 }, async () => { + assert.fail('a live cached entry must not re-execute'); + }); + assert.equal(replay.replayed, true); + assert.deepEqual(replay.result, { content: 'newer' }); + }); +}); diff --git a/specs/470-dev-platform-plugin/core-decoupling-checklist.md b/specs/470-dev-platform-plugin/core-decoupling-checklist.md index 61057527..74e2c330 100644 --- a/specs/470-dev-platform-plugin/core-decoupling-checklist.md +++ b/specs/470-dev-platform-plugin/core-decoupling-checklist.md @@ -242,3 +242,67 @@ example path. 4. Bulk moves: zones 8, 9, 11, 14, 15a-c, 16. 5. Zone 10 migrations — last, own PR, snapshot-restored test. 6. Zone 17 CI + the new repo's publishing pipeline. + +--- + +## Baseline raises + +`scripts/check-core-decoupling.mjs` only ever lowers its baseline automatically. +A raise is hand-edited, and each one is argued for here. A raise is legitimate +only when the added references **leave core with the extraction**; a raise that +absorbs new *core* coupling is the ratchet failing at its job. + +### 3306 -> 3441 - W2-2 long-running task seam + +> Re-based after `main` advanced (#552, #553): main lowered its own baseline to 3306 by dropping +> dev-platform cross-references from API-key comments, so this raise is +135, not the +138 first +> recorded. `middleware/packages` is deliberately set to 97, BELOW main's 99 - the generic task +> seam was scrubbed of implementor names and that gain is locked in rather than left as headroom. + + +The W2-2 unit generalised the existing `dev_job` machinery into a reusable +`TaskStore` seam plus `defineLongRunningTool()`, with `dev_job` as its first +implementor. Net +138 after decoupling work. + +**Not counted, because it was removed rather than absorbed (−40).** The generic +seam — `packages/harness-orchestrator/src/tasks/` — initially named `dev_job` 25 +times in its own doc comments, and the generic web-ui task card another 5. A +generic abstraction documented in terms of one implementor leaks the coupling it +exists to remove, and every one of those references would dangle the moment the +Dev Platform leaves. They were rewritten to state the contract instead. **The +generic seam is now CLEAN — `middleware/packages` sits at its baseline of 97 and +must stay there.** Also removed: two inaccurate comments (a pg test claiming to +seed "dev-platform surfaces" when it seeds only `mcp_*` and `agents`; a `ci.yml` +comment enumerating the features whose schema lives in `middleware/migrations`, +which was the only dev-platform reference in any workflow and was descriptive, +not functional). + +**Absorbed into the raise (+138).** Broken down honestly: + +| Count | Where | Leaves with extraction? | +|---|---|---| +| 62 | `src/devplatform/devJobTaskStore.ts` (new) | yes — inside the extracted folder | +| 4 | `src/devplatform/devJobStore.ts` (W3-A sweep scope) | yes — inside the extracted folder | +| 47 | `test/devplatform/devJobTaskStore.pg.test.ts` (new) | yes | +| 24 | `test/devplatform/devJobTaskStoreReap.test.ts` (new) | yes | +| 1 | `web-ui/.../tasks/__tests__/taskChatCardState.test.ts` | **no** | + +The 137 are an **adapter and its tests**. `devJobTaskStore.ts` exists precisely +to adapt `dev_job` to the generic seam; naming `dev_job` is its entire job. +Contorting it to reduce a number would make the adapter worse and would not move +a single line out of core any earlier — it already lives in the folder the +extraction deletes. It is deliberately not reduced. + +The 1 genuine core reference is +`expect(isTaskStartToolName('dev_job_start')).toBe(true)`. It is kept because it +is *true and load-bearing*: the generic `_start` suffix predicate does match +`dev_job_start`, which is exactly why `chat/page.tsx` must test the bespoke +dev-job card **before** the generic one. Deleting the assertion to save a count +would be dodging the regex, not decoupling. It disappears on extraction along +with the card it guards. + +One structural fix rode along: `devJobTaskStoreReap.test.ts` was written into +`test/tasks/` (the *generic* seam's test directory) despite importing only from +`src/devplatform/`. Moved to `test/devplatform/` so the extraction picks it up +with its siblings. Ratchet-neutral — same zone — but it is 24 references that +would otherwise have been stranded in a directory nobody would think to move. diff --git a/specs/470-dev-platform-plugin/decoupling-baseline.json b/specs/470-dev-platform-plugin/decoupling-baseline.json index f89dec4a..7763ab17 100644 --- a/specs/470-dev-platform-plugin/decoupling-baseline.json +++ b/specs/470-dev-platform-plugin/decoupling-baseline.json @@ -1,15 +1,15 @@ { - "total": 3306, + "total": 3448, "zones": { - "middleware/src": 1636, - "middleware/test": 966, - "middleware/packages": 99, + "middleware/src": 1702, + "middleware/test": 1043, + "middleware/packages": 97, "middleware/scripts": 8, "middleware/sidecars": 195, "middleware/migrations": 70, "middleware/package.json": 0, "middleware/env-example": 19, - "web-ui/app": 227, + "web-ui/app": 228, "web-ui/messages": 4, "web-ui/config": 0, "ci-workflows": 16, diff --git a/web-ui/app/_components/chat/McpInputCard.tsx b/web-ui/app/_components/chat/McpInputCard.tsx new file mode 100644 index 00000000..05f912d4 --- /dev/null +++ b/web-ui/app/_components/chat/McpInputCard.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; + +import { Button } from '../ui/Button'; +import type { PendingMcpInput } from '../../_lib/chatSessions'; + +/** + * Issue #544 (W2-1) — mid-call input form for an MCP tool that answered + * `resultType: "input_required"`. + * + * Sibling of `ChoiceCard`, deliberately not a variant of it: a choice card is + * 2-4 mutually exclusive buttons the MODEL chose, this is N free-text fields a + * THIRD-PARTY SERVER demanded. Submitting fires a fresh user turn carrying the + * reply envelope, which the orchestrator resolves and replays. + * + * ## Server attribution is a security control, not a label + * + * An MCP server can now make omadia render arbitrary prose and collect arbitrary + * free text mid-conversation. Without naming the asker, a hostile server could + * phish credentials behind omadia's own chrome. So this card: + * + * - names the server in the heading, prominently, always; + * - renders the server's `prompt` inside a visually distinct quote block, so + * untrusted prose cannot read as omadia speaking; + * - carries an explicit warning that the values go to that external server; + * - uses a neutral/warning treatment rather than the accent colour the rest of + * omadia's own UI uses for its own questions. + * + * `secret: true` masks the input, but the hint says plainly that the value still + * reaches the server — claiming otherwise would be worse than not masking. + */ + +/** + * Prefix of the synthetic user message the answer rides back on. MUST stay + * byte-identical to `MCP_INPUT_REPLY_PREFIX` in + * `middleware/packages/harness-orchestrator/src/mcp/pendingMcpInput.ts`. + * Duplicated rather than imported: web-ui does not depend on the middleware + * packages, and `middleware/test/mcpInputReplyContract.test.ts` pins the pair. + */ +export const MCP_INPUT_REPLY_PREFIX = '__mcp_input_reply__'; + +/** Build the envelope the next turn carries. */ +export function formatMcpInputReply( + correlationId: string, + inputResponses: Record, +): string { + return `${MCP_INPUT_REPLY_PREFIX} ${JSON.stringify({ + correlationId, + inputResponses, + })}`; +} + +export function McpInputCard({ + request, + disabled, + onSubmit, +}: { + request: PendingMcpInput; + disabled: boolean; + /** Submits the envelope as a fresh user turn. */ + onSubmit: (message: string) => void; +}): React.ReactElement { + const t = useTranslations('chat'); + const [values, setValues] = useState>({}); + const [submitted, setSubmitted] = useState(false); + + const missingRequired = request.fields.some( + (f) => f.required === true && (values[f.name] ?? '').trim().length === 0, + ); + const locked = disabled || submitted; + + const submit = (): void => { + if (locked || missingRequired) return; + // Only fields the user actually filled in travel; an untouched optional + // field is absent rather than an empty string, so the server can tell + // "skipped" from "explicitly empty". + const payload: Record = {}; + for (const field of request.fields) { + const value = values[field.name]; + if (value !== undefined && value.length > 0) payload[field.name] = value; + } + setSubmitted(true); + onSubmit(formatMcpInputReply(request.correlationId, payload)); + }; + + return ( +
{ + e.preventDefault(); + submit(); + }} + > +
+ + {t('mcpInput.kicker')} +
+ {/* Attribution — the security-relevant line. Never conditional. */} +
+ {t('mcpInput.heading', { + server: request.serverName, + tool: request.toolName, + })} +
+ {request.prompt !== undefined && request.prompt.length > 0 && ( + // Quoted + attributed: untrusted server prose must not read as omadia's + // own copy. +
+ {request.prompt} +
+ )} +
+ {request.fields.map((field) => { + const id = `mcp-input-${request.correlationId}-${field.name}`; + return ( +
+ + {field.description !== undefined && ( + + {field.description} + + )} + { + setValues((prev) => ({ ...prev, [field.name]: e.target.value })); + }} + className="rounded border border-[color:var(--edge)] bg-[color:var(--bg-default)] px-2 py-1 text-sm text-[color:var(--fg-strong)]" + /> + {field.secret === true && ( + // Honest, not reassuring: masking is a display choice only. + + {t('mcpInput.secretHint')} + + )} +
+ ); + })} +
+

+ {t('mcpInput.warning', { server: request.serverName })} +

+
+ +
+
+ ); +} diff --git a/web-ui/app/_components/chat/__tests__/McpInputCard.test.tsx b/web-ui/app/_components/chat/__tests__/McpInputCard.test.tsx new file mode 100644 index 00000000..8a3255d6 --- /dev/null +++ b/web-ui/app/_components/chat/__tests__/McpInputCard.test.tsx @@ -0,0 +1,141 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import type { PendingMcpInput } from '../../../_lib/chatSessions'; +import { renderWithIntl } from '../../../_lib/test-utils'; +import { + MCP_INPUT_REPLY_PREFIX, + McpInputCard, + formatMcpInputReply, +} from '../McpInputCard'; + +const REQUEST: PendingMcpInput = { + correlationId: 'corr-abc', + serverName: 'Kunden-CRM', + serverId: 'srv-1', + toolName: 'create_ticket', + prompt: 'Bitte Kundennummer und PIN angeben.', + fields: [ + { name: 'customerNumber', label: 'Kundennummer', required: true }, + { name: 'pin', label: 'PIN', secret: true }, + { name: 'note', label: 'Notiz', description: 'Optional' }, + ], +}; + +describe('#544 W2-1 McpInputCard', () => { + it('MUTATION CHECK: names the asking server', () => { + renderWithIntl( + {}} />, + ); + // The security control: a hostile MCP server must not be able to render a + // credential prompt that reads as omadia's own UI. Removing `serverName` + // from the heading turns this red. + expect( + screen.getByText(/“Kunden-CRM” needs additional details for “create_ticket”/), + ).toBeInTheDocument(); + }); + + it('warns that the values leave for an external server', () => { + renderWithIntl( + {}} />, + ); + // Two independent mentions: the heading and the explicit warning line. + expect(screen.getAllByText(/Kunden-CRM/).length).toBeGreaterThan(1); + }); + + it("renders the server's prompt as quoted, attributed text", () => { + renderWithIntl( + {}} />, + ); + const quote = screen.getByText('Bitte Kundennummer und PIN angeben.'); + expect(quote.tagName.toLowerCase()).toBe('blockquote'); + }); + + it('renders one labelled input per field and masks secrets', () => { + renderWithIntl( + {}} />, + ); + expect(screen.getByLabelText(/Kundennummer/)).toHaveAttribute('type', 'text'); + expect(screen.getByLabelText(/PIN/)).toHaveAttribute('type', 'password'); + expect(screen.getByLabelText(/Notiz/)).toBeInTheDocument(); + }); + + it('MUTATION CHECK: submit stays disabled until every required field is filled', async () => { + renderWithIntl( + {}} />, + ); + const submit = screen.getByRole('button'); + expect(submit).toBeDisabled(); + // Filling an OPTIONAL field must not unlock it — a required-field check that + // merely counted non-empty inputs would pass without this step. + await userEvent.type(screen.getByLabelText(/Notiz/), 'egal'); + expect(submit).toBeDisabled(); + await userEvent.type(screen.getByLabelText(/Kundennummer/), 'K-1234'); + expect(submit).toBeEnabled(); + }); + + it('MUTATION CHECK: submits the envelope with the correlation id and only filled fields', async () => { + const onSubmit = vi.fn(); + renderWithIntl( + , + ); + await userEvent.type(screen.getByLabelText(/Kundennummer/), 'K-1234'); + await userEvent.type(screen.getByLabelText(/PIN/), '9876'); + await userEvent.click(screen.getByRole('button')); + + expect(onSubmit).toHaveBeenCalledTimes(1); + const message = onSubmit.mock.calls[0]![0] as string; + // Asserting the PARSED payload, not that a callback fired: the orchestrator + // resolves this exact shape, so a malformed envelope would be a silent + // no-op in production. + expect(message.startsWith(MCP_INPUT_REPLY_PREFIX)).toBe(true); + const parsed = JSON.parse(message.slice(MCP_INPUT_REPLY_PREFIX.length)) as { + correlationId: string; + inputResponses: Record; + }; + expect(parsed.correlationId).toBe('corr-abc'); + // The untouched optional field is ABSENT, not an empty string, so the server + // can tell "skipped" from "explicitly empty". + expect(parsed.inputResponses).toEqual({ customerNumber: 'K-1234', pin: '9876' }); + }); + + it('MUTATION CHECK: cannot be submitted twice', async () => { + const onSubmit = vi.fn(); + renderWithIntl( + , + ); + await userEvent.type(screen.getByLabelText(/Kundennummer/), 'K-1'); + const submit = screen.getByRole('button'); + await userEvent.click(submit); + await userEvent.click(submit); + // The correlation id is single-use server-side, so a second submit could + // only ever fail. Dropping the `submitted` latch turns this into 2. + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(submit).toBeDisabled(); + }); + + it('is inert while a turn is in flight', async () => { + const onSubmit = vi.fn(); + renderWithIntl( + , + ); + expect(screen.getByRole('button')).toBeDisabled(); + expect(screen.getByLabelText(/Kundennummer/)).toBeDisabled(); + }); + + it('renders without a server prompt', () => { + const { prompt: _drop, ...noPrompt } = REQUEST; + renderWithIntl( + {}} />, + ); + // Attribution survives even when the server sent no prose at all. + expect(screen.getAllByText(/Kunden-CRM/).length).toBeGreaterThan(0); + expect(screen.queryByRole('blockquote')).not.toBeInTheDocument(); + }); + + it('formatMcpInputReply is a stable, parseable envelope', () => { + const wire = formatMcpInputReply('c1', { a: 'b' }); + expect(wire).toBe(`${MCP_INPUT_REPLY_PREFIX} {"correlationId":"c1","inputResponses":{"a":"b"}}`); + }); +}); diff --git a/web-ui/app/_components/mcp/McpAuthSection.tsx b/web-ui/app/_components/mcp/McpAuthSection.tsx index e2e22d0d..ff9679d8 100644 --- a/web-ui/app/_components/mcp/McpAuthSection.tsx +++ b/web-ui/app/_components/mcp/McpAuthSection.tsx @@ -9,6 +9,7 @@ import { disconnectMcpServer, getMcpAuthStatus, setMcpOAuthClient, + setMcpServerDelegation, type McpAuthStatus, } from '@/app/_lib/agentBuilder'; @@ -87,6 +88,26 @@ export function McpAuthSection({ } } + /** Flip the delegation mode (W0-1). Surfaced here because it decides WHOSE + * authorization every call to this server uses — the same question the rest + * of this panel is about. */ + async function toggleDelegation(): Promise { + if (!status?.delegation) return; + setBusy(true); + setError(null); + try { + await setMcpServerDelegation( + serverId, + status.delegation === 'per_user' ? 'service' : 'per_user', + ); + await refresh(); + } catch (err) { + setError(errText(err)); + } finally { + setBusy(false); + } + } + async function saveClient(): Promise { if (!status?.issuer || clientId.trim() === '') return; setBusy(true); @@ -115,6 +136,33 @@ export function McpAuthSection({ {t('auth.notConnected')} )} + {/* W2-4 — which acquisition mode this issuer is on. A badge rather than + a sentence because it is a persistent property of the server, and + because `manual` must read as a normal state, not a warning. */} + {status.acquisitionMode === 'cimd' ? ( + + {t('auth.modeCimd')} + + ) : null} + {status.acquisitionMode === 'manual' ? ( + + {t('auth.modeManual')} + + ) : null} + {status.acquisitionMode === 'dcr' ? ( + + {t('auth.modeDcr')} + + ) : null} {status.connected ? ( + +
+ {status.delegation === 'per_user' + ? t('auth.delegationPerUserWhy') + : t('auth.delegationServiceWhy')} +
+ {status.delegation === 'per_user' && status.identityResolved === false ? ( +
+ {t('auth.delegationIdentityMissing')} +
+ ) : null} ) : null} {showClientForm ? ( @@ -148,6 +244,12 @@ export function McpAuthSection({
{t('auth.needsClientWhy', { host: status.issuerHost ?? status.issuer ?? '?' })}
+ {/* W2-4 — say plainly that this form IS the enterprise path, so nobody + reads it as a stopgap until CIMD arrives. It never will for Entra + ID or Okta: they use pre-registered app registrations by design. */} +
+ {t('auth.manualIsEnterprisePath')} +
{status.redirectUri ? (
{t('auth.redirectUri')}:{' '} diff --git a/web-ui/app/_components/tasks/TaskChatCard.tsx b/web-ui/app/_components/tasks/TaskChatCard.tsx new file mode 100644 index 00000000..e29bd6d8 --- /dev/null +++ b/web-ui/app/_components/tasks/TaskChatCard.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +import { taskCardLabel, type TaskCardSeed } from './taskChatCardState'; + +/** + * W2-2 (issue #543) — the generic long-running task card, rendered inline in + * chat whenever the orchestrator calls any `_start` from the task seam. + * + * Deliberately minimal, and deliberately NOT a live view. A tool that has an + * authorized SSE event tail and a human gate to offer registers its own richer + * card; a generic task has neither — its only read path is the + * model calling `_status`. So this card states what was started and that + * the answer arrives separately, which is the honest UX for the deferred shape. + * + * PRIVACY: renders seed metadata only (ids, kind, progress label). The task + * result is never on the card — it is delivered through `_status`, whose + * return value passes the orchestrator's `dispatchTool` privacy pass. Adding a + * result field here would route it around the Privacy Shield data plane. + * + * Lume: state is text/edge only — no spinners. + */ +export function TaskChatCard({ seed }: { seed: TaskCardSeed }): React.ReactElement { + const t = useTranslations('chat.task'); + const label = taskCardLabel(seed.tool); + + return ( +
+
+ {t('started', { label })} + {t('running')} +
+
+ {t('deferredHint')} +
+
+ {t('taskId', { id: seed.taskId })} + {seed.phase ? ` · ${t('phase', { phase: seed.phase })}` : null} +
+
+ ); +} diff --git a/web-ui/app/_components/tasks/__tests__/taskChatCardState.test.ts b/web-ui/app/_components/tasks/__tests__/taskChatCardState.test.ts new file mode 100644 index 00000000..547ad7ee --- /dev/null +++ b/web-ui/app/_components/tasks/__tests__/taskChatCardState.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { + isTaskStartToolName, + parseTaskStartResult, + taskCardLabel, +} from '../taskChatCardState'; + +/** + * W2-2 (issue #543) — the generic task card's parser. + * + * The card is the only thing a user sees when a tool defers, so a false positive + * (rendering a card for a non-task tool) and a false negative (dropping a real + * handle back to a plain tool row) are both user-visible bugs. + */ +describe('parseTaskStartResult', () => { + it('parses a task_started result into a seed', () => { + const seed = parseTaskStartResult( + JSON.stringify({ + status: 'task_started', + taskId: 't-1', + tool: 'ask_research_start', + kind: 'subagent.Research', + phase: 'queued', + }), + ); + expect(seed).toEqual({ + taskId: 't-1', + tool: 'ask_research_start', + kind: 'subagent.Research', + phase: 'queued', + }); + }); + + it('defaults phase to queued when absent', () => { + expect( + parseTaskStartResult(JSON.stringify({ status: 'task_started', taskId: 't-2' }))?.phase, + ).toBe('queued'); + }); + + it('returns null for an Error refusal string', () => { + expect(parseTaskStartResult('Error: `question` must be a non-empty string.')).toBeNull(); + }); + + it('returns null for a start payload using a different status envelope', () => { + // A tool that ships its own richer card emits its own status value, not + // `task_started`. The generic card must not hijack it. + expect( + parseTaskStartResult( + JSON.stringify({ status: 'job_started', jobId: 'j-1', repoId: 'r-1' }), + ), + ).toBeNull(); + }); + + it('returns null for a missing or empty taskId', () => { + expect(parseTaskStartResult(JSON.stringify({ status: 'task_started' }))).toBeNull(); + expect( + parseTaskStartResult(JSON.stringify({ status: 'task_started', taskId: '' })), + ).toBeNull(); + expect( + parseTaskStartResult(JSON.stringify({ status: 'task_started', taskId: 7 })), + ).toBeNull(); + }); + + it('returns null for prose, malformed JSON, undefined and non-objects', () => { + expect(parseTaskStartResult(undefined)).toBeNull(); + expect(parseTaskStartResult('')).toBeNull(); + expect(parseTaskStartResult('just some prose')).toBeNull(); + expect(parseTaskStartResult('{not json')).toBeNull(); + expect(parseTaskStartResult('[1,2,3]')).toBeNull(); + expect(parseTaskStartResult('null')).toBeNull(); + }); +}); + +describe('isTaskStartToolName', () => { + it('matches only the seam start half', () => { + expect(isTaskStartToolName('ask_research_start')).toBe(true); + expect(isTaskStartToolName('dev_job_start')).toBe(true); + expect(isTaskStartToolName('ask_research_status')).toBe(false); + expect(isTaskStartToolName('ask_research_list')).toBe(false); + expect(isTaskStartToolName('query_knowledge_graph')).toBe(false); + }); +}); + +describe('taskCardLabel', () => { + it('strips the ask_ prefix and _start suffix', () => { + expect(taskCardLabel('ask_research_start')).toBe('research'); + expect(taskCardLabel('ask_odoo_hr_start')).toBe('odoo hr'); + }); + + it('falls back to the raw name when there is nothing to strip', () => { + expect(taskCardLabel('weird')).toBe('weird'); + }); + + it('degrades to a non-empty label for a degenerate name', () => { + // `ask_start` -> strip `_start` -> `ask`; the `^ask_` strip no longer + // matches, so the label is `ask` rather than empty. Never blank. + expect(taskCardLabel('ask_start')).toBe('ask'); + expect(taskCardLabel('_start')).toBe('_start'); + }); +}); diff --git a/web-ui/app/_components/tasks/taskChatCardState.ts b/web-ui/app/_components/tasks/taskChatCardState.ts new file mode 100644 index 00000000..b10499d3 --- /dev/null +++ b/web-ui/app/_components/tasks/taskChatCardState.ts @@ -0,0 +1,72 @@ +/** + * W2-2 (issue #543) — pure helpers for the GENERIC long-running task card. + * + * Delivery mechanism, and why it needs no new stream event: a task started from + * chat surfaces as an ordinary `_start` tool call in the chat stream (a + * `tool_use` paired with its `tool_result`). The tool result is the seam's + * documented contract string + * `{"status":"task_started","taskId":…,"tool":…,"kind":…,"phase":"queued"}`. + * The chat UI detects any tool whose name ends in `_start`, parses the seed + * below, and renders a card. This is the tool-agnostic sibling of the bespoke + * per-tool card parsers, which use the same mechanism for their own tool. + * + * PRIVACY: the seed is metadata only (ids, kind, progress label). The task's + * RESULT deliberately never rides on the card: it is delivered by the model + * calling `_status`, whose return value passes through the orchestrator's + * `dispatchTool` privacy pass. See `describeDeferredPrivacyPosture()` in + * `middleware/packages/harness-orchestrator/src/tasks/longRunningTool.ts`. + */ + +/** The seed a `_start` tool result carries into the card. */ +export interface TaskCardSeed { + taskId: string; + /** The `_start` tool that produced it. */ + tool: string; + kind: string; + phase: string; +} + +/** + * Parse a `_start` tool-result string. Returns a seed only for a + * successful launch (`status === 'task_started'` with a string `taskId`); + * returns `null` for a refusal / `Error:` string or any non-launch payload, so + * the caller falls back to the plain tool row. + */ +export function parseTaskStartResult(output: string | undefined): TaskCardSeed | null { + if (!output) return null; + const trimmed = output.trim(); + if (!trimmed.startsWith('{')) return null; // `Error: …` and prose never parse + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) return null; + const obj = parsed as Record; + if (obj['status'] !== 'task_started') return null; + const taskId = obj['taskId']; + if (typeof taskId !== 'string' || taskId.length === 0) return null; + const tool = typeof obj['tool'] === 'string' ? obj['tool'] : ''; + const kind = typeof obj['kind'] === 'string' ? obj['kind'] : ''; + const phase = typeof obj['phase'] === 'string' ? obj['phase'] : 'queued'; + return { taskId, tool, kind, phase }; +} + +/** + * Human label for a `_start` name: `ask_research_start` → `research`. + * Falls back to the raw name so an unrecognised shape still reads sensibly. + */ +export function taskCardLabel(toolName: string): string { + const base = toolName.replace(/_start$/, '').replace(/^ask_/, ''); + return base.length > 0 ? base.replace(/_/g, ' ') : toolName; +} + +/** + * Does this tool name look like the seam's start half? Used to decide whether to + * even attempt a parse, so a normal tool's JSON output is never mistaken for a + * task handle. + */ +export function isTaskStartToolName(name: string): boolean { + return name.endsWith('_start'); +} diff --git a/web-ui/app/_lib/__tests__/stripStaleInteractives.test.ts b/web-ui/app/_lib/__tests__/stripStaleInteractives.test.ts new file mode 100644 index 00000000..6cf26057 --- /dev/null +++ b/web-ui/app/_lib/__tests__/stripStaleInteractives.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { + stripStaleInteractives, + type Message, + type PendingMcpInput, +} from '../chatSessions'; + +/** + * #544 W2-1 — a mutation run found this logic completely uncovered while it was + * inline in `chat/page.tsx`, including the pre-existing `pendingUserChoice` + * half. A stale MCP input form is the worst case: its `correlationId` is + * single-use server-side, so re-submitting an old form can only fail. + */ + +const MCP_INPUT: PendingMcpInput = { + correlationId: 'corr-1', + serverName: 'Kunden-CRM', + serverId: 'srv-1', + toolName: 'create_ticket', + fields: [{ name: 'customerNumber', required: true }], +}; + +function assistant(over?: Partial): Message { + return { + id: 'm1', + role: 'assistant', + content: 'answer', + tools: [], + ...over, + } as Message; +} + +describe('#544 stripStaleInteractives', () => { + it('MUTATION CHECK: strips a stale MCP input form', () => { + const out = stripStaleInteractives([assistant({ pendingMcpInput: MCP_INPUT })]); + expect(out[0]!.pendingMcpInput).toBeUndefined(); + // The key must be GONE, not merely falsy — the render guard is a truthiness + // check, but an `undefined`-valued key would still serialize into storage. + expect('pendingMcpInput' in out[0]!).toBe(false); + }); + + it('MUTATION CHECK: strips a stale choice card and follow-ups (pre-existing behaviour)', () => { + const out = stripStaleInteractives([ + assistant({ + pendingUserChoice: { question: 'Q?', options: [{ label: 'A', value: 'a' }] }, + followUpOptions: [{ label: 'more', prompt: 'more' }], + }), + ]); + expect('pendingUserChoice' in out[0]!).toBe(false); + expect('followUpOptions' in out[0]!).toBe(false); + }); + + it('strips all three at once', () => { + const out = stripStaleInteractives([ + assistant({ + pendingMcpInput: MCP_INPUT, + pendingUserChoice: { question: 'Q?', options: [{ label: 'A', value: 'a' }] }, + followUpOptions: [{ label: 'more', prompt: 'more' }], + }), + ]); + expect('pendingMcpInput' in out[0]!).toBe(false); + expect('pendingUserChoice' in out[0]!).toBe(false); + expect('followUpOptions' in out[0]!).toBe(false); + }); + + it('keeps everything else on the message', () => { + const out = stripStaleInteractives([ + assistant({ pendingMcpInput: MCP_INPUT, content: 'keep me', turnId: 't1' }), + ]); + expect(out[0]!.content).toBe('keep me'); + expect(out[0]!.turnId).toBe('t1'); + expect(out[0]!.role).toBe('assistant'); + }); + + it('MUTATION CHECK: returns untouched messages by IDENTITY', () => { + const plain = assistant(); + const out = stripStaleInteractives([plain]); + // Reference equality matters: React short-circuits re-renders on it, so a + // version that always spread every message would re-render the whole + // transcript on every send. `toEqual` would not catch that. + expect(out[0]).toBe(plain); + }); + + it('does not mutate the input', () => { + const input = assistant({ pendingMcpInput: MCP_INPUT }); + stripStaleInteractives([input]); + expect(input.pendingMcpInput).toEqual(MCP_INPUT); + }); + + it('handles an empty transcript', () => { + expect(stripStaleInteractives([])).toEqual([]); + }); +}); diff --git a/web-ui/app/_lib/agentBuilder.ts b/web-ui/app/_lib/agentBuilder.ts index 433a3769..d3160d7a 100644 --- a/web-ui/app/_lib/agentBuilder.ts +++ b/web-ui/app/_lib/agentBuilder.ts @@ -202,15 +202,29 @@ export interface McpDiscoveredTool { name: string; description?: string; inputSchema?: Record; + /** Issue #547 (W1-3) — the tool's declared JSON-Schema for its + * `structuredContent` payload. Present ⇒ the tool returns structured output + * in addition to text. Read-only signal for the operator. */ + outputSchema?: Record; verdict?: McpToolVerdictField; } export type McpTransport = 'stdio' | 'http' | 'sse'; +/** + * Transports MCP 2026-07-28 deprecated (issue #541). Mirrors the middleware's + * `DEPRECATED_MCP_TRANSPORTS`; the union above deliberately keeps `'sse'` — + * legacy servers stay fully usable during the 12-month removal window, they are + * only discouraged for new registrations. + */ +export const DEPRECATED_MCP_TRANSPORTS: readonly McpTransport[] = ['sse']; + export interface McpServerNode { id: string; name: string; transport: McpTransport; + /** Issue #541 — server-derived deprecation flag; absent on older middleware. */ + transportDeprecated?: boolean; endpoint: string | null; status: NodeStatus; lastDiscoveredAt: string | null; @@ -644,13 +658,28 @@ export interface McpCallLogEntry { serverId: string | null; serverName: string; toolName: string; - callerKind: 'agent' | 'subagent' | 'skill' | 'plugin' | 'unattributed'; + /** W2-3 (#542) — `api_key` is a call that arrived over the public MCP + * endpoint from a third party holding an API key: no orchestrator turn, no + * sub-agent, no plugin. Migration 0033 widened the matching DB CHECK. */ + callerKind: 'agent' | 'subagent' | 'skill' | 'plugin' | 'unattributed' | 'api_key'; callerAgent: string | null; turnId: string | null; ok: boolean; error: string | null; durationMs: number; calledAt: string; + /** + * W0-1 — WHOSE authority the call acted under. `callerAgent` names the + * orchestrator; this names the identity its credentials belonged to + * (`apikey:` for a public MCP call, an MCP user key otherwise). The + * literal `unresolved` marks a call that had no identity to act as. + * + * The backend has returned this since the `mcp_call_log.acting_identity` + * column landed; it was simply never surfaced, which left "whose credentials + * touched that server?" unanswerable in the UI — the exact question the + * column was added to answer. + */ + actingIdentity: string | null; } export async function listMcpGrants(): Promise<{ grants: McpGrantMatrixRow[] }> { @@ -804,6 +833,8 @@ export interface McpCatalogEntry { description: string | null; version: string | null; transport: McpTransport | null; + /** Issue #541 — the catalog only offered a deprecated (HTTP+SSE) remote. */ + transportDeprecated?: boolean; endpoint: string | null; license: string | null; author: string | null; @@ -856,15 +887,49 @@ export async function importMcpServerFromRegistry( // ── Generic MCP OAuth (issue #459 W9) ──────────────────────────────────────── +/** Whose authority MCP calls to a server act under (W0-1). `per_user` requires + * each caller to have its own identity and fails closed without one; + * `service` is the explicit opt-in to one shared identity. */ +export type McpDelegation = 'per_user' | 'service'; + export interface McpAuthStatus { protected: boolean; connected: boolean; issuer: string | null; issuerHost?: string | null; - /** The server offers Dynamic Client Registration — connecting is zero-setup. */ + /** A client can be acquired with no operator setup — via a Client ID Metadata + * Document (W2-4) or working Dynamic Client Registration. */ brokered?: boolean; + /** W2-4 — which link of the client-acquisition chain this issuer resolves + * through. `manual` is the permanent, supported Entra ID / Okta path, NOT a + * failure state: neither IdP supports CIMD. */ + acquisitionMode?: 'stored' | 'cimd' | 'dcr' | 'manual'; + /** W2-4 — the authorization server advertised + * `client_id_metadata_document_supported`. Independent of whether THIS install + * can serve the document (that needs inbound https reachability). */ + cimdSupported?: boolean; + /** W2-4 — why CIMD is unavailable although the AS supports it. Almost always + * because this deployment is not inbound-reachable, which is normal on-prem. */ + cimdBlockedReason?: string | null; needsClient: boolean; redirectUri?: string; + /** W0-1 — the server's delegation mode. */ + delegation?: McpDelegation; + /** W0-1 — whether this session has an identity to act as. False on a + * `per_user` server means every call fails closed until an identity is + * available or the operator opts into `service` delegation. */ + identityResolved?: boolean; +} + +/** Switch a server's delegation mode (W0-1). */ +export async function setMcpServerDelegation( + serverId: string, + delegation: McpDelegation, +): Promise<{ id: string; delegation: McpDelegation }> { + return callJson(`/v1/operator/mcp-servers/${encodeURIComponent(serverId)}/delegation`, { + method: 'PUT', + body: JSON.stringify({ delegation }), + }); } export async function getMcpAuthStatus(serverId: string): Promise { @@ -974,6 +1039,78 @@ export async function exportSkill(id: string): Promise { return text; } +// ----------------------------------------------------------------------------- +// Public MCP key bindings (W5-1) +// ----------------------------------------------------------------------------- + +/** + * A row of `public_mcp_key_bindings` — the per-API-key allowlist behind the + * public MCP endpoint. `enabled: false` is a PARKED binding: the key reaches + * nothing, but what it was configured to reach is still on the row. + */ +export interface PublicMcpKeyBinding { + keyId: string; + agentId: string; + readTools: string[]; + writeTools: string[]; + writeRateLimitPerMinute: number; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface PublicMcpKeyBindingsResponse { + bindings: PublicMcpKeyBinding[]; +} + +export interface UpsertPublicMcpKeyBindingInput { + keyId: string; + agentId: string; + readTools: string[]; + writeTools: string[]; + writeRateLimitPerMinute?: number; + /** OMIT to leave the revoked/active state exactly as stored. Sending `true` + * RE-ARMS a revoked key, so this pane never sends it implicitly — un-parking + * goes through `restorePublicMcpKeyBinding` instead. */ + enabled?: boolean; +} + +export async function listPublicMcpKeyBindings(): Promise { + return callJson('/v1/operator/public-mcp-bindings'); +} + +export async function upsertPublicMcpKeyBinding( + input: UpsertPublicMcpKeyBindingInput, +): Promise<{ binding: PublicMcpKeyBinding }> { + return callJson<{ binding: PublicMcpKeyBinding }>('/v1/operator/public-mcp-bindings', { + method: 'POST', + body: JSON.stringify(input), + }); +} + +/** Parks the binding. Deliberately not a delete — the configured reach stays + * visible so an operator can see what the integration used to have. */ +export async function revokePublicMcpKeyBinding( + keyId: string, +): Promise<{ binding: PublicMcpKeyBinding }> { + return callJson<{ binding: PublicMcpKeyBinding }>( + `/v1/operator/public-mcp-bindings/${encodeURIComponent(keyId)}/revoke`, + { method: 'POST' }, + ); +} + +/** Un-parks a revoked binding, restoring the reach it already had on the row. + * Its own call, not a side effect of saving: a save that never mentions + * `enabled` deliberately CANNOT re-arm a key an operator revoked. */ +export async function restorePublicMcpKeyBinding( + keyId: string, +): Promise<{ binding: PublicMcpKeyBinding }> { + return callJson<{ binding: PublicMcpKeyBinding }>( + `/v1/operator/public-mcp-bindings/${encodeURIComponent(keyId)}/restore`, + { method: 'POST' }, + ); +} + // ----------------------------------------------------------------------------- // MCP servers // ----------------------------------------------------------------------------- diff --git a/web-ui/app/_lib/chatSessions.ts b/web-ui/app/_lib/chatSessions.ts index eb76133e..d8dd3b5b 100644 --- a/web-ui/app/_lib/chatSessions.ts +++ b/web-ui/app/_lib/chatSessions.ts @@ -95,6 +95,66 @@ export interface PendingUserChoice { options: Array<{ label: string; value: string }>; } +/** + * Strip every one-shot interactive affordance from older assistant messages. + * + * Called when a new user message is sent, so button rows and input forms + * disappear from the history instead of inviting a second click. Extracted from + * `chat/page.tsx` to be testable: a mutation run showed the inline version was + * completely uncovered, and a stale MCP input form is worse than a stale button + * row — its `correlationId` is single-use server-side, so re-submitting it can + * only fail. + * + * Pure and non-mutating: messages with nothing to strip are returned by + * identity, so React's reference equality still short-circuits their re-render. + */ +export function stripStaleInteractives(messages: readonly Message[]): Message[] { + return messages.map((m) => { + if (!m.pendingUserChoice && !m.followUpOptions && !m.pendingMcpInput) return m; + const { + pendingUserChoice: _dropChoice, + pendingMcpInput: _dropMcpInput, + followUpOptions: _dropFollowUps, + ...rest + } = m; + return rest; + }); +} + +/** One free-text field an MCP server asked for (#544 W2-1). */ +export interface McpInputCardField { + name: string; + label?: string; + description?: string; + /** + * Render the input masked. ADVISORY: the value still travels to the + * third-party MCP server verbatim, so the UI must not imply it is protected. + */ + secret?: boolean; + required?: boolean; +} + +/** + * Mid-call input request from an MCP tool (#544 W2-1, MRTR + * `resultType: "input_required"`). The turn ended so the user can fill the + * fields in; submitting sends a fresh turn carrying the reply envelope, and the + * orchestrator replays the parked tool call. + * + * Mirrors the backend's `PendingMcpInputCard`. A SIBLING of + * {@link PendingUserChoice}, not a variant of it: free-text fields, not buttons. + * + * `serverName` MUST be rendered — see `McpInputCard.tsx`. + */ +export interface PendingMcpInput { + correlationId: string; + serverName: string; + serverId: string; + toolName: string; + /** Server-supplied prose. UNTRUSTED text — render as text, never as markup. */ + prompt?: string; + fields: McpInputCardField[]; +} + /** * Non-blocking 1-click refinement options attached below an answer. Each * click submits `prompt` as a fresh user message. Mirrors the backend's @@ -408,6 +468,13 @@ export interface Message { * the buttons disappear on re-renders of the conversation history. */ pendingUserChoice?: PendingUserChoice; + /** + * #544 W2-1 — set when the turn ended because an MCP tool needs mid-call user + * input. Cleared once the user submits (or types a fresh message) so the form + * disappears on re-renders of the conversation history, exactly like + * `pendingUserChoice`. + */ + pendingMcpInput?: PendingMcpInput; /** * Refinement buttons attached below the answer (from `suggest_follow_ups`). * Cleared once the user commits to a follow-up or types a fresh message, diff --git a/web-ui/app/_lib/chatStreamEvents.ts b/web-ui/app/_lib/chatStreamEvents.ts index cc28907c..556b993c 100644 --- a/web-ui/app/_lib/chatStreamEvents.ts +++ b/web-ui/app/_lib/chatStreamEvents.ts @@ -14,6 +14,7 @@ import type { NudgeEvent, OutgoingFileAttachment, PalaiaExcerpt, + PendingMcpInput, PendingUserChoice, PlanSnapshot, PrivacyReceipt, @@ -129,6 +130,8 @@ export type ChatStreamEvent = attachments?: DiagramAttachment[]; fileAttachments?: OutgoingFileAttachment[]; pendingUserChoice?: PendingUserChoice; + /** #544 W2-1 — see `Message.pendingMcpInput`. Sibling of the above. */ + pendingMcpInput?: PendingMcpInput; followUpOptions?: FollowUpOption[]; privacyReceipt?: PrivacyReceipt; maskedValues?: readonly string[]; @@ -358,6 +361,9 @@ function foldIntoMessage(m: Message, event: ChatStreamEvent): Message { ...(event.pendingUserChoice ? { pendingUserChoice: event.pendingUserChoice } : {}), + ...(event.pendingMcpInput + ? { pendingMcpInput: event.pendingMcpInput } + : {}), ...(event.followUpOptions && event.followUpOptions.length > 0 ? { followUpOptions: event.followUpOptions } : {}), @@ -478,7 +484,7 @@ function mergeKgInsert( } } - const edgeKey = (e: KgWalkEdge): string => `${e.from}${e.to}${e.type}`; + const edgeKey = (e: KgWalkEdge): string => `${e.from}\0${e.to}\0${e.type}`; const insertedEdgeKeys = new Set(insert.edges.map(edgeKey)); const edges: KgWalkEdge[] = prior.edges.map((e) => insertedEdgeKeys.has(edgeKey(e)) ? { ...e, inserted: true } : e, diff --git a/web-ui/app/admin/mcp/__tests__/page.test.tsx b/web-ui/app/admin/mcp/__tests__/page.test.tsx new file mode 100644 index 00000000..ee00edda --- /dev/null +++ b/web-ui/app/admin/mcp/__tests__/page.test.tsx @@ -0,0 +1,130 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithIntl } from '../../../_lib/test-utils'; +import type { McpServerNode } from '../../../_lib/agentBuilder'; +import AdminMcpPage from '../page'; + +/** + * Issue #541 — MCP 2026-07-28 deprecates the legacy HTTP+SSE transport. + * + * The operator picker must therefore not offer `sse` by default (http / + * Streamable HTTP stays the default), but must NOT hard-block it either: the + * removal window is at least 12 months, so re-creating a legacy SSE server has + * to stay possible behind the "show deprecated transports" toggle. Existing sse + * rows keep working and are badged rather than hidden. + */ + +const { mockListMcpServers } = vi.hoisted(() => ({ mockListMcpServers: vi.fn() })); + +// Spread the real module so DEPRECATED_MCP_TRANSPORTS (the shared source of +// truth this UI derives from) stays genuine; only the network call is stubbed. +vi.mock('../../../_lib/agentBuilder', async (importOriginal) => ({ + ...(await importOriginal()), + listMcpServers: mockListMcpServers, +})); + +function server(overrides: Partial): McpServerNode { + return { + id: 'srv-1', + name: 'srv', + transport: 'http', + endpoint: 'https://x.example/mcp', + status: 'enabled', + lastDiscoveredAt: null, + discoveredTools: [], + ...overrides, + } as McpServerNode; +} + +const LEGACY = server({ + id: 'srv-sse', + name: 'legacy-sse', + transport: 'sse', + transportDeprecated: true, + endpoint: 'https://legacy.example/sse', +}); +const MODERN = server({ + id: 'srv-http', + name: 'modern-http', + transport: 'http', + transportDeprecated: false, +}); + +beforeEach(() => { + mockListMcpServers.mockReset(); + mockListMcpServers.mockResolvedValue({ servers: [LEGACY, MODERN] }); +}); + +async function renderServersPane(): Promise { + renderWithIntl(); + await waitFor(() => expect(mockListMcpServers).toHaveBeenCalled()); + return (await screen.findByLabelText('Transport')) as HTMLSelectElement; +} + +function optionValues(select: HTMLSelectElement): string[] { + return within(select) + .getAllByRole('option') + .map((o) => (o as HTMLOptionElement).value); +} + +describe('MCP admin transport picker (#541)', () => { + it('hides the deprecated sse transport by default and defaults to http', async () => { + const select = await renderServersPane(); + expect(optionValues(select)).toEqual(['http', 'stdio']); + expect(select.value).toBe('http'); + }); + + it('offers sse once deprecated transports are shown — never hard-blocked', async () => { + const user = userEvent.setup(); + const select = await renderServersPane(); + + await user.click(screen.getByLabelText('Show deprecated transports')); + + expect(optionValues(select)).toEqual(['http', 'stdio', 'sse']); + // Labelled so the operator cannot pick it unaware of the deprecation. + expect(within(select).getByRole('option', { name: 'sse (deprecated)' })).toBeTruthy(); + + // …and it is genuinely selectable: an operator must still be able to + // register a legacy SSE server during the removal window. + await user.selectOptions(select, 'sse'); + expect(select.value).toBe('sse'); + }); + + it('resets a selected sse back to http when the toggle is switched off again', async () => { + const user = userEvent.setup(); + const select = await renderServersPane(); + const toggle = screen.getByLabelText('Show deprecated transports'); + + await user.click(toggle); + await user.selectOptions(select, 'sse'); + await user.click(toggle); + + expect(optionValues(select)).toEqual(['http', 'stdio']); + expect(select.value).toBe('http'); + }); + + it('badges an existing sse row as deprecated and leaves http rows unbadged', async () => { + await renderServersPane(); + + const sseRow = (await screen.findByText('legacy-sse')).closest('tr'); + const httpRow = (await screen.findByText('modern-http')).closest('tr'); + expect(sseRow).toBeTruthy(); + expect(httpRow).toBeTruthy(); + + expect(within(sseRow as HTMLElement).getByText('Deprecated')).toBeTruthy(); + expect(within(httpRow as HTMLElement).queryByText('Deprecated')).toBeNull(); + }); + + it('falls back to the local deprecation list when the middleware omits the flag', async () => { + // An older middleware build does not send `transportDeprecated`; the badge + // must still appear so operators are not silently left in the dark. + mockListMcpServers.mockResolvedValue({ + servers: [server({ id: 'old', name: 'old-sse', transport: 'sse' })], + }); + await renderServersPane(); + const row = (await screen.findByText('old-sse')).closest('tr'); + expect(within(row as HTMLElement).getByText('Deprecated')).toBeTruthy(); + }); +}); diff --git a/web-ui/app/admin/mcp/page.tsx b/web-ui/app/admin/mcp/page.tsx index dc1906a9..432e5719 100644 --- a/web-ui/app/admin/mcp/page.tsx +++ b/web-ui/app/admin/mcp/page.tsx @@ -14,6 +14,7 @@ import { ackMcpToolVerdict, addMcpRegistry, createMcpServer, + DEPRECATED_MCP_TRANSPORTS, rescanAllMcpServers, testCallMcpTool, deleteGraphEdge, @@ -29,6 +30,10 @@ import { listMcpPluginCandidates, listMcpRegistries, listMcpServers, + listPublicMcpKeyBindings, + revokePublicMcpKeyBinding, + restorePublicMcpKeyBinding, + upsertPublicMcpKeyBinding, revokeMcpGrant, revokePluginMcpServer, searchMcpCatalog, @@ -46,10 +51,11 @@ import { type McpRegistryInfo, type McpServerNode, type McpTransport, + type PublicMcpKeyBinding, type SkillVerdictSeverity, } from '../../_lib/agentBuilder'; -type Tab = 'servers' | 'marketplace' | 'grants' | 'plugins' | 'audit'; +type Tab = 'servers' | 'marketplace' | 'grants' | 'plugins' | 'bindings' | 'audit'; /** * MCP Control Center v1 (epic #459 W2, issues #460/#461/#462): the standalone @@ -76,7 +82,7 @@ export default function AdminMcpPage(): React.ReactElement { {t('intro')}

- {(['servers', 'marketplace', 'grants', 'plugins', 'audit'] as const).map((k) => ( + {(['servers', 'marketplace', 'grants', 'plugins', 'bindings', 'audit'] as const).map((k) => (
@@ -201,6 +208,15 @@ function worstSeverityOf(server: McpServerNode): SkillVerdictSeverity { return worst; } +/** + * Issue #541 — badge an existing row whose transport MCP 2026-07-28 deprecated. + * Trusts the middleware's `transportDeprecated` when present and falls back to + * the local list, so the badge still shows against an older middleware build. + */ +function isDeprecatedTransport(server: McpServerNode): boolean { + return server.transportDeprecated ?? DEPRECATED_MCP_TRANSPORTS.includes(server.transport); +} + function ServersPane({ onAssign }: { onAssign: (serverId: string) => void }): React.ReactElement { const t = useTranslations('adminMcp'); const [servers, setServers] = useState(null); @@ -212,6 +228,11 @@ function ServersPane({ onAssign }: { onAssign: (serverId: string) => void }): Re const [busy, setBusy] = useState(null); const [name, setName] = useState(''); const [transport, setTransport] = useState('http'); + // Issue #541 — MCP 2026-07-28 deprecated the legacy HTTP+SSE transport. It is + // hidden from the picker by default (http/Streamable HTTP stays the default + // choice) but never blocked: the removal window is open, so an operator must + // still be able to register a legacy SSE server on purpose. + const [showDeprecated, setShowDeprecated] = useState(false); const [endpoint, setEndpoint] = useState(''); const refresh = useCallback(async () => { @@ -288,13 +309,35 @@ function ServersPane({ onAssign }: { onAssign: (serverId: string) => void }): Re +