diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index e96b4e76..511eaa39 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -1,5 +1,10 @@ # Troubleshooting +## 2026-09-12 — Listed upstream integration audit + +- This worktree has no `.memory/` or `node_modules/`. Read the main checkout's project memory as historical context, but use this worktree's exact HEAD/source as authority. The main checkout's `tsx` binary can execute dependency-free focused suites without installing packages here; suites with runtime package imports still fail module resolution (observed: `entities` in the subagent capability suite). Treat that as an environment limitation, not a product regression or passing test. +- A repository in the earlier compaction reference table is not necessarily an installed integration. Verify runtime imports, vendored artifacts, implementation history, and explicit adoption decisions before proposing package upgrades. + - `.papercuts/` is ignored even when its troubleshooting file is present in the PR branch, so persisting a required update needs an explicit `git add -f`. - Layout stabilization must race `animation.finished` against a short timeout because paused or infinite document animations never settle; keep geometry polling as the authoritative E2E readiness check. - Pi 0.80.10 can choose the oldest oversized user turn as `firstKeptEntryId`, leaving both summary inputs empty and producing a no-op checkpoint. When the journal has a newer turn, retry `prepareCompaction` with a minimal retained-tail budget; still refuse the checkpoint if both summary inputs remain empty. @@ -429,3 +434,9 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed. - E2E chat-title expectations assume the deterministic chat-model route. On a Mac where the native Foundation Models helper reports `ready`, automatic titles come from Apple Intelligence instead, so `chat-message-queue` sidebar-title lookups fail locally while passing in CI; probe the helper or move it aside before treating those failures as regressions. - `git add` on the tracked-but-ignored `.papercuts/troubleshooting.md` still needs `-f` after conflict resolution. + +## 2026-09-12 — MCP maintenance implementation + +- MCP SDK1.30.0 closes HTTP transports during OAuth redirection but expects finishAuth to reuse the same object and discovered metadata. Restart only its exchange request lifetime, retaining owner cancellation. +- SDK OAuth metadata GETs also send MCP-Protocol-Version; a protocol header alone does not identify a timed MCP RPC. Classify actual request semantics and test the real SDK helpers. SSE per-frame limits apply only to successful requested streams, never arbitrary MIME-labeled JSON/error bodies. +- Physical-iPhone native verification found three failures in unchanged RemoteClient fixture tests (catalog expectation, invalid JSON __SwiftValue, and legacy fallback invalidResponse). See the maintenance acceptance record; Android52 passed, iOS198 passed/3 skipped/3 failed. diff --git a/docs/plans/README.md b/docs/plans/README.md index d487e5a7..6098cf1f 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -32,7 +32,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [rpiv-advisor integration](rpiv-advisor-integration-plan.md) | Implemented | A bounded, tool-free, provider/auth-aware second opinion now uses an ephemeral per-consultation Ask User Question choice when the prompt does not name a reviewer, with no persistent Advisor settings or IPC. | | [rpiv-todo Integration](rpiv-todo-integration-plan.md) | Partial | Attended desktop chats have a journal-replayed native todo tool, strict fail-closed snapshots, owner-fenced IPC, and a self-hiding floating progress chip with portal details; packaged visual/accessibility acceptance remains open. | | [rpiv-btw Integration](rpiv-btw-integration-plan.md) | Partial | Attended desktop chats have bounded read-only side questions, ephemeral fingerprinted follow-ups, foreground-safe admission, exact provider dispatch, content-free usage accounting, and a native slash/card surface; packaged visual/accessibility acceptance remains open. | -| [Subagent Orchestration Expansion](subagent-orchestration-expansion-plan.md) | Active | Phases 0–6, Phase 7A durable lifecycle, the Phase 7B1 storage seam, reboot-safe V1/V2 migration checkpoints, startup/clone-boundary repairs, Pi-normalized foreground presentation/recovery, parent-only iOS/Android projection hardening, and the 2026-09-01 approval/capability/budget failure remediation are complete; app-lifetime coordinator activation is next. | +| [Subagent Orchestration Expansion](subagent-orchestration-expansion-plan.md) | Active | Phases 0–6, Phase 7A durable lifecycle, the Phase 7B1 storage seam, reboot-safe V1/V2 migration checkpoints, startup/clone-boundary repairs, Pi-normalized foreground presentation/recovery, parent-only iOS/Android projection hardening, and the 2026-09-01 approval/capability/budget failure remediation are complete; foreground read-tool prompt truthfulness and refreshed background settlement gates are complete; app-lifetime coordinator activation is next. | | [Taracodlab Learnings](taracodlab-learnings-plan.md) | Partial | Phases A–B and D, plus core Phase E, are implemented; the remaining roadmap is open. | | [Telegram First-Class Agent Parity](telegram-first-class-agent-parity-plan.md) | Active | Controls, compaction, skills, rich inbound, drafts/activity, buttons, documents, Settings, and onboarding are green; Threaded Mode/profiles/extensions/TTS/live smoke remain. | | [Update, Microphone, and Computer Use Hardening](update-microphone-computer-use-hardening-plan.md) | Partial | Installed acceptance found a silent stalled download; observable full-download recovery is implemented, while repaired-build → next-release and clean-TCC acceptance remain. | diff --git a/docs/plans/subagent-orchestration-expansion-plan.md b/docs/plans/subagent-orchestration-expansion-plan.md index fa42d871..0ce78de6 100644 --- a/docs/plans/subagent-orchestration-expansion-plan.md +++ b/docs/plans/subagent-orchestration-expansion-plan.md @@ -746,6 +746,36 @@ Focused coordinator tests passed 3/3 with TypeScript clean. ## Verification matrix +### 2026-09-12 upstream refresh: required before background activation + +The original August 5 design baseline remains historical. The latest comparison +is `nicobailon/pi-subagents@940406c0d48890060d1f5f5280925c8f5a5c7389` +(v0.67.0 plus 23 commits); see +[the integrated-upstream audit](../testing/integrated-upstream-audit-2026-09-12.md). +This refresh does not activate the coordinator or change the Pi runtime pin. + +Production activation must demonstrate all of the following in crash-point +tests and the packaged nested-run soak: + +- Durable acceptance makes the run queryable before child allocation begins. +- Result commit precedes terminal status publication and completion notification. +- Steering distinguishes queued, consumed, and unconsumed-at-shutdown outcomes; + enqueue success alone must never be reported as consumption. +- Descendants and owned tool/process work drain before ancestor completion and + before inference capacity is released. Exercise cancellation during a depth-two + handoff with one completed and one interrupted descendant. +- Unresolved external effects remain `unknown`; incomplete inference remains + `interrupted`. Neither outcome causes an automatic effect retry on restart. +- Corrupt status degrades to a bounded partial record without inventing completion. +- Unchanged status does not repeatedly wake or notify the parent. +- Desktop Remote, iOS, and Android preserve parent-only child projections. + +The foreground workspace-read prompt now follows the actual assembled tool +intersection. An empty tool set remains valid for task/fork-only analysis; role +names do not imply a missing capability or authorize widening access. + +### Existing final gates + Every final gate includes: - strict TypeScript and lint; diff --git a/docs/testing/integrated-upstream-audit-2026-09-12.md b/docs/testing/integrated-upstream-audit-2026-09-12.md new file mode 100644 index 00000000..80a872b7 --- /dev/null +++ b/docs/testing/integrated-upstream-audit-2026-09-12.md @@ -0,0 +1,224 @@ +# Aiden integrated upstream audit — 12 September 2026 + +## Decision context and audit boundary + +This report answers which of the ten supplied links require work in Aiden, what to change, and how to validate that work. The two MattDevy links point to one monorepo, so the list contains nine distinct projects/package sources. + +The audited product is Aiden 0.40.0 at commit `a4c85c6d8`, in `/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent`. This is a fixed source snapshot, not an assertion about subsequent main-branch commits, other open worktrees, or a running installed application. Research clones are under `/Users/sambitbiswas/projects/opp`. Their refreshed commit identities are recorded below. Product source, dependency pins, credentials, deployed releases, and rollout stages were not changed by this audit. + +Aiden embeds `@earendil-works/pi-agent-core` and `@earendil-works/pi-ai`, both at **0.84.4**, and the MCP SDK at **1.30.0**. None of the supplied third-party plugin packages is a direct installed dependency. The meaningful maintenance scope is Aiden's native implementations that deliberately adopted ideas or contracts from those projects. Updating a research clone does not update the application. + +The scope check used `package.json`, the lockfile, runtime imports, vendored resources, integration documentation, implementation history, and the existing upstream audit table. Mere presence in that table does not establish an integration. In particular, a common name such as Computer Use, Memory, or permissions does not prove that the corresponding external plugin powers the feature. + +Project memory was read from the main checkout because this worktree initially had no `.memory/` directory. That memory is historical context; the audited worktree's source and tracked plans take precedence where they differ. The earlier reference audit is dated **31 August 2026**, while the subagent expansion plan also records a **5 August** baseline. Findings distinguish changes since these dates from older, deliberately unfinished Aiden work. + +## Verification performed + +This is a research and implementation-planning audit, not a completed upgrade or release acceptance run. + +- Executed the current MCP result and approval suites: **9 tests passed**. +- Executed the current memory-store and web-search-core suites: **14 tests passed**. +- Reproduced a foreground MCP normalizer defect using synthetic data: a text-plus-image result returned only its text; a result containing one million synthetic base64 characters became a **1,000,063-character** JSON text block. No real screenshots, credentials, or external tool calls were used. +- Attempted the subagent request-capability suite. It could not load because this worktree has no installed dependencies and the runtime import of `entities` was unresolved. That is an environment limitation, not a confirmed product test failure. The successful suites used the existing main checkout's `tsx` executable without installing or changing dependencies. +- Full desktop, native mobile, signed-package, credential-backed provider, and background-soak suites were not run. Their relevant acceptance requirements are specified with the proposed work. + +The MCP reproduction demonstrates loss of content and absence of a bound in that normalizer. It does **not** establish an application crash or unlimited model context. `generation-context.ts` has a 32,000-character tool-text reduction, but it runs only after `shouldCompact(...)` becomes true; lower-pressure requests bypass it. It is therefore not an unconditional tool-result limit. Applying that later reduction cannot recover discarded images or structured evidence, and it does not prevent the earlier JSON-string allocation. + +The successful commands were run from the audited worktree using `/Users/sambitbiswas/projects/aiden-agent/node_modules/.bin/tsx --test`, first with `main/services/mcp-tool-result.test.ts main/services/tool-approval.test.ts`, then with `main/services/memory-store.test.ts main/services/web-search-core.test.ts`. The additional attempted file was `main/services/subagents/request-capabilities-v2.test.ts`. The installed runner was only a test launcher; it did not substitute another checkout's product source. + +## Executive decision + +**Do a focused maintenance pass on Aiden's native MCP integration. Do not run a blanket plugin upgrade.** The highest-priority finding is configured service headers crossing an OAuth origin boundary. A second confirmed defect loses multimodal and structured MCP evidence. OAuth network deadlines deserve a companion reliability change. Web access already covers the relevant upstream fixes examined. Context-mode has no source change to adopt. Subagents need a narrower capability-contract investigation and a background-lifecycle design refresh, rather than importing hundreds of upstream changes. + +These findings do not establish that a secret has actually leaked, that users have suffered a crash, or that the current release is universally broken. They identify reproducible code paths and the conditions under which they matter. No incident history or production telemetry was examined. + +| Order | Decision | Priority and confidence | Delivery boundary | +| --- | --- | --- | --- | +| 1 | Scope configured MCP service headers to their intended origin, including OAuth discovery and redirects | P1; confirmed conditional disclosure path with mocked SDK discovery | Aiden-owned transport fix and regression tests | +| 2 | Replace lossy/unbounded MCP result normalization with a bounded, explicit content policy | P1; reproduced content loss and early large allocation | First-party normalizer, transport admission, projection tests | +| 3 | Bound OAuth network requests without shortening the human sign-in window | P2; source-supported reliability gap | Same transport workstream, separately testable change | +| 4 | Investigate effective child-tool/prompt disagreement and add a narrow regression | P2; source concern, production reachability must be proven | Subagent assembly/prompt contract; no authority expansion | +| 5 | Refresh background-subagent acceptance requirements before existing Phase 7 activation | P2 prerequisite for that feature, not an active-runtime release blocker | Existing orchestration plan and lifecycle tests | +| 6 | Keep current web-provider implementations; record upstream fixes already covered | No code change established by this comparison | Provenance update only | +| 7 | Keep context-mode-derived memory and current local compaction ownership | No required update | No dependency or engine changes | +| 8 | Exclude nonintegrated plugins and newly added upstream providers | Out of this maintenance scope | No installation, migration, or feature expansion | + +Here P1 means address in the next focused maintenance cycle; P2 means planned reliability work or a prerequisite before exposing the affected feature. No P0 emergency or proven active exploitation was established. + +## Integration inventory and explicit exclusions + +| Supplied source | Actual relationship to Aiden | Audit disposition | +| --- | --- | --- | +| `mksglu/context-mode` | Design-derived native memory/retrieval; no imported package or MCP owner | Compare the adopted source concepts; no source delta | +| `nicobailon/pi-subagents` | Deliberate native adaptation with Aiden-owned tools, approvals, persistence and UI | Compare applicable behavior; selective follow-up | +| `nicobailon/pi-web-access` | Native provider/search adaptation and catalog research | Compare shipped provider behavior; retain current implementations | +| `nicobailon/pi-mcp-adapter` | Reference for Aiden's existing native MCP feature; independent SDK lifecycle | Use relevant upstream fixes as evidence to inspect Aiden; no plugin bump | +| `DietrichGebert/ponytail` | Workflow/simplicity reference only | Excluded | +| `@gotgenes/pi-permission-system` | Policy reference; native one-shot approval machinery is independent | Excluded as a package update | +| `MattDevy/pi-extensions` | Memory confidence/feedback and review research only | Excluded | +| `MattDevy/pi-extensions/packages/pi-simplify` | Same monorepo; code-review reference, not context compaction | Excluded | +| `narumiruna/pi-extensions` | Provider-native compaction research; deliberately deferred | Scope checked; no integrated package to update | +| `injaneity/pi-computer-use` | Media/replay research reference | Excluded; not Aiden's Computer Use driver | + +The authoritative adoption decisions are in [the reference ledger](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/docs/plans/pi-compaction-memory-upgrade-implementation-notes.md:65) and [the research scope](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/docs/plans/pi-compaction-memory-upgrade-plan.md:74). The latter explicitly separates research from permission to install third-party extensions. + +The Computer Use distinction matters. Aiden uses **trycua/cua driver 0.8.3**, source `0612c26b2c7b8556f6de7f6b4f3927ecac914e4f`, behind its own authenticated Rust broker. Its model-facing contract came from Hermes. The originating Aiden commit is `34bbc84274c34dfe13f41f82c7638ee73047aaad`, dated 22 July. This is recorded in [the Computer Use architecture](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/docs/computer-use-integration.md:3) and [the pinned artifact manifest](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/resources/computer-use/cua-driver-artifact.json:3). Updating injaneity's plugin would not update that driver. A separate CUA currency audit was not added to the user's supplied scope. + +Likewise, Aiden's native confidence field does not establish an installation of pi-continuous-learning, and the existing Allow once/Deny machinery does not establish an installation of gotgenes' permission package. Their recorded versions are research artifacts, not production pins. None of those excluded repositories was cloned merely to enumerate unrelated changes. + +## Refreshed source ledger + +These are the exact research snapshots, not proposed production dependencies. All five research checkouts were clean at verification. Existing checkouts were inspected before pulling; absent repositories were cloned into the requested `opp` directory. + +| Repository and local directory under `opp` | Recorded comparison baseline | Refreshed HEAD | Refresh outcome | +| --- | --- | --- | --- | +| `pi-subagents` | Expansion: `6209b8b035f02d031f23f160840131719f115d51`; later compaction audit: `3f879722f96fdec19364ccd9a18f8176d797fedc` | `940406c0d48890060d1f5f5280925c8f5a5c7389` | Existing clean checkout fast-forwarded from `f6a4caccfddcce04e18afd7fcf28d225ac5b45ad`; v0.67.0 plus 23 commits | +| `pi-web-access` | `5741f303a4f5b89fed18e02ec3fed038844e0e98` | `192ac1875e3b8f88c78953dbc314949ec9fcaa27` | Existing clean checkout fast-forwarded from v0.27.0-era `8f11a0a`; current package v0.29.0; 33 commits after ledger baseline | +| `pi-mcp-adapter` | `ff234b862359e722bf4dc1c99cde62278d4b8eb3` | `6f4a8f86f2da5f2acaa8e536396c61296752310b` | New clone; v2.33.0 plus one commit; 40 commits after baseline | +| `context-mode` | `6b8bf61f83abed6c3faf4e7c3ba02c162fadfedf` | `0c57e6e5a167c19da33e29f1c6158c39728ba260` | New clone; only generated statistics changed | +| `narumiruna-pi-extensions` | `36c2421544f0defaebd3d44b793d39b2a7f5fb47` | `07ac1d7446deb28472030770a537590427da2dae` | New clone for bounded compaction/scope verification; relevant package 0.51.3 → 0.52.0 | + +Upstream source comparisons: [subagents since the August expansion baseline](https://github.com/nicobailon/pi-subagents/compare/6209b8b035f02d031f23f160840131719f115d51...940406c0d48890060d1f5f5280925c8f5a5c7389), [web access](https://github.com/nicobailon/pi-web-access/compare/5741f303a4f5b89fed18e02ec3fed038844e0e98...192ac1875e3b8f88c78953dbc314949ec9fcaa27), [MCP adapter](https://github.com/nicobailon/pi-mcp-adapter/compare/ff234b862359e722bf4dc1c99cde62278d4b8eb3...6f4a8f86f2da5f2acaa8e536396c61296752310b), [context-mode](https://github.com/mksglu/context-mode/compare/6b8bf61f83abed6c3faf4e7c3ba02c162fadfedf...0c57e6e5a167c19da33e29f1c6158c39728ba260), [Narumi](https://github.com/narumiruna/pi-extensions/compare/36c2421544f0defaebd3d44b793d39b2a7f5fb47...07ac1d7446deb28472030770a537590427da2dae). + +## 1. MCP: prevent configured service headers crossing OAuth origins + +**Finding.** Both [normal MCP transport construction](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/mcp.ts:115) and [interactive OAuth transport construction](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/mcp-oauth.ts:207) pass configured `server.headers` as SDK `requestInit.headers`. In pinned SDK 1.30.0, the transport constructs `createFetchWithInit(fetch, requestInit)` and supplies that merged fetch to OAuth discovery. Its merge attaches the base headers without checking the destination origin. + +An MCP service can legitimately advertise an authorization server on a different origin. If the configured MCP headers include a service-specific API key or tenant credential, that unrelated authorization origin can receive it. This concerns configured service headers on the relevant HTTP/SSE OAuth path. It is not evidence that every Pi inference key, every encrypted MCP token, or every connection is affected. + +**Reproduction.** A fake fetch returned protected-resource metadata for `https://service.example/mcp` declaring `https://auth.example` as its authorization server. The real SDK discovery function ran through its real `createFetchWithInit` helper, with `X-Service-Key: synthetic-marker` as the configured base header. The fake recorded that header on both origins. No socket was opened and no real credential was used. This independently corroborates the source trace; it is not a live exploit of Aiden. + +Upstream addressed the same class in [origin-scoped service-header forwarding](https://github.com/nicobailon/pi-mcp-adapter/commit/08299641c6378381d4634dd6bb5a1cde42300ce3). The useful update is that policy, implemented inside Aiden's existing lifecycle. + +**Implementation.** Introduce one shared, testable fetch policy used by ordinary HTTP, SSE, interactive authorization, token completion/refresh, and verification connections. Attach configured service headers only to the exact configured service origin. Do not leave secret headers in a shared base initializer that the SDK can reuse for other destinations. Handle redirects explicitly so an originally approved request cannot carry arbitrary custom headers to a different origin. Preserve SDK-generated OAuth authentication for its legitimate destination: simply rejecting all cross-origin OAuth discovery would break normal deployments. + +Retain existing generation checks, document ownership, cancellation, credential observers/redaction, no-redirect preset behavior, and the stronger bounded subagent fetch. A new general transport wrapper must compose with those controls, not replace them. Avoid persisting headers or full request objects in diagnostics. + +**Acceptance.** Add a fixture with separate MCP and authorization origins; verify a synthetic service header exists only at the MCP origin. Exercise HTTP and SSE, initial discovery, dynamic registration where used, token exchange, refresh, and final authenticated connection. Include same-origin discovery, cross-origin redirects, mixed-case header names, token-specific Authorization headers, and server URL/header edits during authorization. The old valid session must survive a failed replacement authorization. This change can be implemented without new product UI or mobile wire fields. + +**Release assessment.** Prioritize this fix first. A live credential check, if desired after deterministic tests, should use a disposable test service and disposable credentials. This audit establishes the conditional path; it does not establish an actual disclosure incident or justify asserting one to users. + +## 2. MCP: preserve meaningful results and bound them before context projection + +**Finding.** [mcp-tool-result.ts](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/mcp-tool-result.ts:3) collects text blocks and returns them if any text exists. Images, audio, resource links, embedded resources, and separate structured content are then omitted. If there is no text, it serializes the whole result as JSON, including inline binary/base64 content. The normal [MCP tool call path](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/mcp.ts:382) uses this function. + +The synthetic mixed response contained text, an image, and `structuredContent: { answer: 42 }`. The result retained only the text. The image-only reproduction generated a 1,000,063-character string from one million synthetic base64 characters. The existing three MCP tests cover identity, a normal text result, and `isError`; all pass, and none tests this content matrix. + +**Impact.** The agent can lose the evidence needed to answer correctly, or consume a base64 dump as text instead of seeing an image or an explicit unsupported-content notice. Late context truncation cannot restore discarded evidence. It also occurs after the conversion allocated the large JSON string. The separate 32,000-character reduction is conditional on compaction pressure, so a below-threshold request can retain a larger string. This does not establish a crash or unlimited prompt size. The amount retained in every journal and presentation path should be measured during implementation rather than assumed. + +The subagent MCP path already applies stricter bounds and explicit result policy. Preserve that smaller authority surface. Upstream's [bounded-output work](https://github.com/nicobailon/pi-mcp-adapter/commit/4444b49fbfb23e9ed058ed89f88673b1a54193a3) is a useful reference, but its scripting engine is not needed. + +**Implementation.** Replace the implicit text-or-JSON fallback with a versioned, explicit normalizer policy. Keep bounded text; preserve bounded structured evidence when it adds information; validate and deliver images through Aiden's existing allowed image path only when the receiving model/surface supports them. Return a clear bounded descriptor for unsupported content. Never use an automatic stringify fallback for arbitrary binary envelopes, and never automatically fetch a returned resource link. + +Define aggregate bytes, per-block bytes, text length, media count, and decoded-image limits. Use stable truncation or omission markers rather than presenting an incomplete value as complete. Keep resolved `isError: true` as a tool failure, and bound/sanitize its message. Inspect admission before JSON/body materialization separately: a post-parse result cap does not cap transport memory. HTTP, SSE, and stdio need policies compatible with their transport semantics; a large stdio response cannot be fixed by an HTTP wrapper. + +Do not silently change the read-only child MCP contract to permit images, resources, or broader network activity. Where normalization code is shared, select the policy from host-owned authority and surface capabilities. + +**Acceptance.** Add text-only, mixed text/image, image-only, structured-only, text-plus-structured, audio, embedded resource, resource link, empty, malformed, oversized, and error fixtures. Assert no raw base64 enters model text. Check the declared bounds before provider invocation, the resulting journal entry, text-only-model behavior, and cancellation while receiving a result. Retain existing stable tool identity and terminal failure tests. + +Because this may affect transcript/activity/media projection, inspect both iOS and Android consumers and run their focused suites even if the eventual public wire format stays unchanged. Reuse existing attachment/artifact contracts where practical; any new field must be versioned and backward-compatible. + +## 3. MCP OAuth: bound network work separately from user sign-in + +**Finding.** Aiden has generation fencing, transactional session replacement, cancellation, and a five-minute callback wait. These controls do not establish a dedicated deadline for each OAuth discovery, registration, or token fetch. The source has no explicit per-fetch policy for those phases. A stalled request can keep sign-in pending until a broader timeout or cancellation occurs. Upstream added [composed OAuth HTTP timeouts](https://github.com/nicobailon/pi-mcp-adapter/commit/6ba7d360fcc67a77ccbbb4921586614798020a7a). + +**Implementation.** Extend the shared transport policy from item 1 with an explicit OAuth-request deadline, initially using upstream's 30-second value as a candidate to validate. Compose the request deadline with existing owner/operation cancellation. Clear timers and abort listeners on every settlement path. Keep the human browser sign-in allowance separate; waiting for the user is not a stalled network request. Preserve the transactional old session when discovery, registration, exchange, or verification fails. + +**Acceptance.** Use a controlled never-resolving fetch for each phase, verify bounded failure and actual request abortion, and prove retry/sign-in is possible afterward. Test owner reload, configuration edits, cancellation during token completion, and a successful browser callback near the human deadline. Expose a stable retryable category without raw provider text or request headers. Do not add periodic reconnection or sign-in background work as part of this fix. + +### Other MCP upstream changes: disposition + +The 40-commit delta contains important changes that do not map to Aiden's implementation. Recording why they were excluded avoids treating the three selected fixes as an incomplete blanket sync. + +| Upstream change | Why it is not immediate Aiden work | +| --- | --- | +| Cross-process OAuth credential transactions | Aiden owns an in-process mutation queue and per-server operation/configuration generations. No equivalent shared adapter credential-file writer was found. Revisit if that ownership changes. | +| Remove inherited `bearerTokenStore` on transport switches | Aiden's server schema has no such inherited field or adapter config-merging system. Its own connection fingerprints and credential cleanup remain the relevant tests. | +| Session-scoped grants and approval-broker ordering | Aiden's one-shot approvals and immutable child ceilings have different owners and semantics. Importing session grants would change authority. | +| MCP script intermediates, namespace proxies, direct-tool search, plugin loading and panels | Those adapter execution/UI modules are not integrated. Their absence is not a regression. | +| Custom CA bundles and disabling inherited stdio environment | Potential separate product/hardening controls. No adopted contract currently promises them; adding them requires explicit configuration and compatibility design. | +| Stale OAuth-client re-registration | Include a failing-case check during the OAuth work, but do not alter Aiden's transactional credential lifecycle without reproducing the same failure. | +| Dynamic callback ports | Aiden has a stable callback URI and explicit port-conflict failure. Changing that requires registration/compatibility work, not an automatic patch port. | +| Node 20 compatibility, extension peer pins, config CLI parsing, TUI themes and install flows | Upstream packaging/runtime-specific changes with no matching Aiden dependency or feature. | + +These dispositions are source-based maintenance decisions, not assertions that the corresponding Aiden areas could never be improved. The pinned MCP comparison in the ledger provides the complete upstream history. + +## 4. Subagents: selective contract hardening, not an extension transplant + +Aiden's [native integration plan](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/docs/plans/completed/aiden-native-subagents-plan.md:7) deliberately uses embedded Pi children and Aiden-owned authority. The active feature has positive capability selection, exact mutation approvals, bounded tree budgets, safe fork projection, and isolated provider execution. Current upstream adopted [native foreground sessions](https://github.com/nicobailon/pi-subagents/commit/d9bc62f8eb82cb67edcdef09ccc4dcad46e5e42d), which aligns with Aiden's direction but does not make its implementation interchangeable. + +The refreshed upstream is v0.67.0 plus 23 commits. Even against the more recent 31 August ledger baseline, the comparison contains 221 commits and changes 416 files. These counts describe upstream churn, not required Aiden work. Its movement from Aiden's older 0.41.0 expansion reference is substantial, but raw commit volume exaggerates Aiden's update burden: much of it concerns upstream CLI/TUI workflows, custom agents, schedules, steering, and detached-run infrastructure that Aiden does not expose. + +**Effective tools and prompt truthfulness.** Upstream now diagnoses [requested inspection tools that disappear from the effective child tool plan](https://github.com/nicobailon/pi-subagents/commit/6f68c6971ec8153b1e68161f1d3804859c9b6702). In Aiden, [capability-tools.ts](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/subagents/capability-tools.ts:26) can return an empty tool list after capability intersection. The V2 child prompt can separately derive workspace-read wording from the requested authority boolean in [subagent-child-runner.ts](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/subagents/subagent-child-runner.ts:585). + +That is a contract question worth testing, but an empty list alone is not a bug. A fork-only analysis, explicitly tool-free task, or MCP-only child may be valid. The initial specialist suggestion to fail all affected roles was therefore narrowed: first prove a reachable production case where the request expects workspace inspection but the final tool list cannot supply it, then derive prompt claims from the assembled tool set or return a precise unavailable-capability result. Never infer new authority from `scout`, `planner`, or `reviewer` labels. + +Tests should distinguish missing expected tools from intentionally unavailable workspace tools, and cover fresh/fork, legacy/V2, MCP-only, write-only, and nested inheritance. This is P2 pending that production trace, not a confirmed P1 failure in ordinary foreground use. + +**Thinking and Pi compatibility.** Upstream fixed [fork behavior that should preserve requested thinking after signed blocks are removed](https://github.com/nicobailon/pi-subagents/commit/a0b8c6de98f642c808e4a36a91124d0fd8f02d46). Aiden already strips hidden/signed reasoning from child context and separately passes its thinking setting. Upstream's new native implementation expects newer Pi seams, but that is not evidence that Aiden's custom harness must immediately upgrade from 0.84.4. Keep a signed-Anthropic-fork regression on the next planned Pi upgrade; require a reproduced Aiden defect or a consciously adopted new seam before expanding this audit into a dependency migration. + +**Background lifecycle.** Aiden's [plan index](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/docs/plans/README.md:35) and [orchestration plan](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/docs/plans/subagent-orchestration-expansion-plan.md:25) leave app-lifetime background activation as follow-on work. The coordinator exists in source/tests but is not wired as a production owner. This is pre-existing unfinished scope, not a newly broken foreground feature. + +Before activation, adopt regression requirements informed by upstream's [durable result publication ordering](https://github.com/nicobailon/pi-subagents/commit/c4a3a3c7b948a126e929d7f50540296bb1aba4d5), [steering-consumption tracking](https://github.com/nicobailon/pi-subagents/commit/57278d82a72983aebda4c966830c5b8d7ef9f183), and [final drain](https://github.com/nicobailon/pi-subagents/commit/808387a206b668b590720050ae4f637d69b6b80c). Specifically: accepted runs must be immediately queryable; durable results must precede completion notifications; queued steering must be distinguishable from consumed steering; descendant/tool/process drain must precede ancestor completion and capacity release; unresolved effects must remain unknown rather than being retried; unchanged status must not repeatedly wake the parent. + +Exercise crash points between acceptance and start, result commit and notification, steering enqueue and consumption, and stop request and terminal proof. Add a packaged depth-two scenario with cancellation during handoff. Preserve exact rollback flags and the existing migration receipts. + +**Do not absorb adjacent feature work.** Upstream structured report schemas and bounded pre-tool provider recovery are useful future ideas. Neither is necessary to maintain Aiden's current text-result and fixed-authority contract. Do not add automatic model/provider fallback, ambient extension discovery, custom CLI agents, schedules, or global context inheritance in this maintenance pass. Model fallback and retries after unknown effects are especially incompatible with an unreviewed authority change. + +The Mac currently owns child detail. Desktop Remote filtering and the iOS/Android contracts preserve parent-only projections; Telegram explicitly disables subagents. A background refresh should retain that boundary unless a separate child-resource protocol is designed. Shared transcript/status changes still require both native-client suites under AGENTS.md. + +## 5. Web access: relevant upstream fixes are already covered + +The existing native implementation is documented in [the Web Access plan](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/docs/plans/web-access-rehaul-plan.md:104). Its source was compared against the refreshed upstream v0.29.0, including the 33 commits since the August audit baseline. + +| Upstream change | Aiden comparison | Decision | +| --- | --- | --- | +| [Tavily plan exhaustion is a quota error](https://github.com/nicobailon/pi-web-access/commit/2cd4f79e5d5d4a0b998d59d62b57ad70998dc8ab) | `web-search-tavily-adapter.ts` already passes 432/433 as quota statuses; focused fixtures assert both | Already covered | +| OpenAI credential origin mismatch protection (`6dff041`) | Native OpenAI adapter and auth-reuse path bind endpoint/credential identity and revalidate after I/O | Preserve existing stronger binding; no blind cherry-pick | +| Preserve cited Perplexity sources (`2f5b0bc`) | Aiden deliberately returns bounded source evidence, with search-result and citations-only coverage, rather than upstream's full answer/report contract | No matching regression established | +| Explicit-only Mistral search (`7ca5cdc`) and SerpApi (`734ab55`) | These are additional upstream providers, not shipped Aiden integrations | Excluded from the requested update scope | +| Extraction, video, repo cloning, response persistence and fan-out changes | These do not belong to Aiden's native search contract | Excluded | + +The relevant code is [Tavily](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/web-search-tavily-adapter.ts:120), [its tests](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/web-search-wave1-adapters.test.ts:371), [OpenAI](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/web-search-openai-adapter.ts:76), and [Perplexity](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/web-search-perplexity-core.ts:291). Exact upstream changes can be inspected in the pinned web comparison in the ledger. + +Do not expand the provider list just to match upstream's count. Aiden's reviewed providers, explicit-only routing, credential isolation, request bounds, and user-selected fallback categories are product constraints. A catalog comment claiming to enumerate every current upstream ID can be dated or clarified without adding a new selectable provider. + +The plan still records credential-backed installed acceptance as outstanding. That is an existing release-owner gate, separate from this source audit. The five current web-search-core tests passed here; they do not replace the live provider matrix. This investigation establishes no necessary provider implementation change from the reviewed delta. + +## 6. Context and memory: no context-mode source update + +Between `6b8bf61...` and `0c57e6e...`, context-mode changed only `stats.json`. Its `src` tree object is identical at both revisions: `259a184cf3e6946d449741b81bb70d7e9bd493bb`. The five files previously audited by Aiden—store, truncation, session database, snapshot and purge—are byte-identical. This is stronger evidence than an unchanged version number: there is no source patch in that comparison to port. + +Aiden's native [memory store](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/memory-store.ts:190) already implements private SQLite/WAL, scoped FTS5/BM25 retrieval, bounded facts and metadata, provenance, expiry/supersession, exact-scope deletion, and source-chat cleanup. [Memory tools](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/memory-context.ts:144) use Aiden's owner approval boundary. The nine memory-store tests passed. Context-mode's generic MCP server, hooks, analytics, sandbox/indexing and session ownership were not adopted. + +Keep the existing native memory implementation. Do not introduce another session owner, a package install, fuzzy indexing, or global purge UI under the label of updating context-mode. Those would be separate feature decisions and no new upstream delta requires them. + +## 7. Narumi compaction: scope verified, defer remains valid + +The relevant upstream package changed from 0.51.3 to 0.52.0 and added [Responses compaction API routing](https://github.com/narumiruna/pi-extensions/commit/ac745429d668d252ae32c1df772a39fd14f78555) for OpenAI/Azure alongside Codex Remote V2. This was checked because Aiden's prior research ledger names it. It remains unintegrated and therefore receives no update work in this report. + +Aiden's [provider-native defer decision](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/docs/testing/pi-compaction-phase7-rollout-gates.md:90) requires proof of checkpoint ownership, model/provider changes, retry/fork behavior, deletion, offline reconstruction, and cross-surface reconciliation. Broader API support alone does not satisfy those requirements. The current extension still relies on coding-agent hooks and opaque remote checkpoints with portability/lifecycle limits; Aiden owns a different harness and local journal. + +Aiden's experimental `pi-vcc` is also not this extension: [its provenance](/Users/sambitbiswas/.codex/worktrees/c65a/aiden-agent/main/services/pi-vcc/UPSTREAM.md:1) points to another source. No new remote engine, automatic fallback, provider request, Pi version bump, or onboarding change is warranted by this scope check. Preserve the original audit baseline as history; this report supplies the dated follow-up observation. + +## Delivery sequence and acceptance gates + +**Change A — OAuth transport isolation.** Implement the origin/header policy and deterministic separate-origin tests first. The primary files are `mcp.ts`, `mcp-oauth.ts`, and a new pure fetch-policy module if needed. Deadline handling can be a second commit in this workstream, with independently passing tests. Run `npm run test:config-recovery`, the new registered transport tests, relevant MCP/subagent fetch tests, and a packaged HTTP/SSE sign-in smoke using disposable test credentials. Verify the old credential transaction remains intact on every failure. + +**Change B — MCP output contract.** Implement the bounded result policy and pre-materialization checks as a separately reviewable change. Run the extended `mcp-tool-result` tests, `npm run test:preflight`, relevant child MCP read/mutation tests, `npm run test:compaction`, and focused transcript/artifact suites. Inspect both native clients and execute their applicable tests. Test text-only and vision models and a synthetic oversized server response in packaged Electron. Keep backward-compatible storage/projection or provide explicit migration tests. + +**Change C — Subagent contract investigation.** First add a failing regression only if the claimed expected/effective tool mismatch is reachable. Fix prompt/tool agreement or return a typed unavailability result without changing authority. Run `npm run test:subagents:inventory`, `npm run test:subagents:phase5b`, `npm run test:subagents:phase6b`, then the full relevant subagent suite and packaged foreground smoke. Keep intentionally tool-free tasks valid. + +**Change D — Existing background plan refresh.** Record the new upstream reference and settlement tests in the existing orchestration plan before activation work. Run existing crash/migration and packaged soak gates when that feature is implemented. Do not report this document update as completion of the background feature. Do not advance the Pi runtime rollout or install a newer Pi package merely to match upstream plugin development pins. + +Register every new test file in the appropriate `package.json` script. If any change touches shared server contracts or transcript/activity behavior, inspect and test both iOS and Android; a desktop-only origin wrapper that leaves those contracts untouched does not itself require mobile UI work. Internal maintenance fixes need no new onboarding tile. A later user-visible capability or privacy/configuration change must follow the onboarding and design rules, but none is proposed here. + +Keep the two confirmed MCP fixes independent enough to revert separately. Prefer ordinary code rollback for stateless transport policy. Do not casually undo a persisted result-envelope change after writing new data: either keep its public/persisted shape backward-compatible or specify reader compatibility and rollback acceptance before release. Do not advance any existing background or compaction feature flag as a side effect of these changes. + +## Research execution and limitations + +The requested model was used: three GPT-5.6 Sol subagents at medium effort completed the initial subagent, web/MCP, and context comparisons. The runtime imposed a hard limit preventing a fourth distinct subagent even after a completed assignment was interrupted. Two subsequent assignments reused those Sol agents for exclusion verification and an adversarial challenge pass, producing five specialist assignments in total. The parent independently checked key source paths, commit references, focused tests, the MCP content reproduction, and the SDK header-origin reproduction. + +The original plan requested four to five distinct explore agents; that exact headcount was not possible under the tool limit. The report does not represent reused agents as additional independent agents. No full dependency install, live-service audit, broad security scan, signed release, mobile build, or production incident investigation is claimed. + +The deliverable changes only this report, local project memory, and a troubleshooting note. Existing implementation-plan statuses are unchanged because this audit did not implement or complete those plans. The next authorized implementation task can use the delivery sequence above without rediscovering the integration inventory. diff --git a/docs/testing/mcp-upgrade-acceptance-2026-09-12.md b/docs/testing/mcp-upgrade-acceptance-2026-09-12.md new file mode 100644 index 00000000..46906710 --- /dev/null +++ b/docs/testing/mcp-upgrade-acceptance-2026-09-12.md @@ -0,0 +1,131 @@ +# Native MCP maintenance — implementation and verification + +Implements the scoped recommendations from +[the September 12 upstream audit](integrated-upstream-audit-2026-09-12.md). +Base: Aiden 0.40.0 at `a4c85c6d8623e0a69d2834c9a0f07f9329fe1129`. +The user authorized implementation and GPT-6 Astra medium review after the audit. + +## Implemented behavior + +- Ordinary and interactive HTTP/SSE connections share one Aiden-owned fetch + policy. Configured service headers attach only to the exact service origin; + SDK headers win case-insensitively, and redirects fail closed. +- OAuth metadata, registration, and token requests have a 30-second deadline + covering headers and body. Owner cancellation and generation checks remain + authoritative. MCP RPCs retain their SDK deadlines, and idle SSE connections + remain long-lived. The five-minute browser callback window is unchanged. +- Closing a transport aborts its owned requests. SDK `finishAuth` can start a new + bounded exchange phase after `Client.connect` closes on authorization redirect, + retaining discovered metadata and the original owner signal. +- Finite decoded response bodies are capped at 8 MiB before SDK materialization. + Successful, requested SSE streams have the same per-frame limit, including + chunked/multiline frames and CR/LF variants. Merely advertising SSE cannot + exempt OAuth JSON or error bodies from a total limit. Stdio explicitly uses + the SDK's existing buffer-limit facility with an 8 MiB cap. The child MCP + lane retains its stricter existing raw/normalized limits. +- MCP results retain bounded text and structured evidence in the existing + `AgentToolResult` text envelope, with a 32,000-character aggregate cap. + Structured projection limits depth, nodes, fields, keys, and string volume; + oversized keys are explicitly omitted rather than renamed. Omission metadata + cannot overwrite real fields. Errors use the same bounded projection. +- Images, audio, and resource blocks receive explicit omission notices. They + are never silently discarded, serialized as raw base64, or automatically + fetched. This maintenance change does not add MCP image rendering or new + attachment, journal, or mobile wire formats. +- Child workspace-read prompt claims now follow actual assembled read tools, + even when a V2 grant allows more than the inherited tool intersection. + Intentionally tool-free analysis remains valid and authority is not rewritten. +- The orchestration plan records durable publication, steering consumption, + descendant drain, interruption, and parent-only projection requirements before + future background activation. That feature remains unactivated. + +No plugin or Pi version bump, provider inventory expansion, model-catalog fetch, +onboarding change, rollout-stage advance, or release publication is part of this +maintenance patch. Existing package-lock pins are unchanged. + +## Automated verification + +| Check | Result | +| --- | --- | +| `npm ci` | Completed using the existing lockfile | +| `npm run test:mcp` | 55 passed; registered after the required native-remover build in pretest, and in preflight | +| `npm run test:config-recovery` | 55 passed | +| Scoped supervisor, child-runtime, request-capability, generation-context, timeline, and Remote protocol tests | 118 passed | +| `npm run test:preflight` | Passed (MCP plus 65 Artificial Analysis, 61 Model Pad, and 230 preflight tests); run before final focused review regressions | +| `npm run test:compaction` | 22 VCC and 279 compaction/runtime tests passed | +| `npm run type-check` | Passed | +| `npm run lint` | Passed | +| `npm run build` | Passed again after all review fixes and focused regressions | +| Android Chat, RemoteClient, and BotContract unit suites | 52 passed on the bundled Android Studio JDK/SDK | +| Physical iPhone 13 Pro: Chat, RemoteClient, and BotContract XCTest suites | 198 passed, 3 skipped, 3 failed in unchanged iOS source | + +The MCP tests use the actual pinned SDK 1.30.0 for HTTP/SSE discovery and token +exchange, plus fake services/credentials. A loopback redirect test verifies that +the destination is never contacted. Other coverage includes header casing and +precedence, Request objects, owner cancellation, stalled headers/body, SDK +metadata carrying protocol headers, same-path token endpoints, long-lived SSE, +cross-chunk frame limits, large multipart/binary results, and structured-key +collisions. No production service credentials were used. + +## Astra review and remediation + +GPT-6 Astra at medium effort reviewed the actual patch and confirmed three P2 +issues in its first pass: MIME-only SSE classification could bypass OAuth JSON +bounds; same-path OAuth endpoints could bypass deadlines; and bounded structured +keys/sentinel properties could collide. Four regressions reproduced those issues +before correction, then passed after correction. + +The parent subsequently identified that SDK metadata requests also carry +`MCP-Protocol-Version`. That header-only timeout exemption was removed: an MCP +request must request SSE, or be a JSON POST carrying the protocol header. +Real SDK protected-resource/authorization-server discovery, stalled metadata +body, and ordinary MCP RPC deadline tests cover that distinction. + +Final Astra re-review: approved within the source/test review scope, with no +remaining actionable findings. The reviewer independently reran all 53 MCP +tests successfully. This approval includes the final SDK metadata deadline +correction and supersedes the earlier review. It does not claim packaged or +live-provider acceptance. + +Pullfrog subsequently identified that the pinned SSE transport sends its first +`initialize` JSON-RPC POST before it has a protocol-version header. Request +classification now recognizes a valid JSON-RPC envelope as MCP traffic, while +same-origin OAuth registration JSON retains the authorization deadline. Two +focused regressions cover both sides of that distinction, bringing the MCP suite +to 55 tests. The initial PR CI run also caught that `test:mcp` had been placed +before the native worktree-remover build required by the main test lifecycle; +the suite now runs immediately after that build, and the 33-test native-remover +suite passes locally. + +## Native client inspection and existing failures + +MCP output remains private model/tool text. No new Remote payload, media block, +timeline enum, or child-resource field is introduced. The existing Mac Remote +protocol, iOS Chat/Remote/Bot contracts, and Android equivalents were inspected +and exercised; neither native implementation needed a change. + +The physical iPhone run used the existing approved `AidenOnTheGo` scheme and +selected only the three relevant suites. The iOS/Android source and shared +fixtures are unchanged relative to the base commit. Three standalone iOS +fixture/client tests failed: + +1. `AidenRemoteClientTests.testBotChatToolsNarrowAccessReconcileFilesAndRevokeWithinExactGrant`: + capability catalog was nil where the fixture expected a catalog. +2. `AidenRemoteClientTests.testChatSummaryDecoderRejectsInvalidActivityOrderingDuplicatesAndBounds`: + fixture JSON serialization raised `NSInvalidArgumentException` for `__SwiftValue`. +3. `AidenRemoteClientTests.testLegacyChatListFallbackRejectsNestedPrivateChildAliases`: + an `invalidResponse` escaped the test. + +These tests execute unchanged native code with fixture transports, not the +modified Electron MCP path. Their failures are recorded as existing native +acceptance failures, not hidden or counted as passes. The result bundle is +`/tmp/aiden-upgrade-ios/Logs/Test/Test-AidenOnTheGo-2026.09.12_00-40-57--0400.xcresult`. + +## Remaining release acceptance + +This is a source/build/test-verified maintenance change, not a signed release. +Credential-backed installed HTTP/SSE sign-in and real-server long-running tool +smokes remain release acceptance work. The byte caps intentionally reject +oversized results; users must narrow such server requests. Raw server responses +must not be spilled to disk or fetched from resource links without a separate +approved artifact policy. diff --git a/main/services/mcp-fetch-policy.test.ts b/main/services/mcp-fetch-policy.test.ts new file mode 100644 index 00000000..4287f3a9 --- /dev/null +++ b/main/services/mcp-fetch-policy.test.ts @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { UnauthorizedError, discoverAuthorizationServerMetadata, discoverOAuthProtectedResourceMetadata, + type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { createMcpFetchPolicy } from "./mcp-fetch-policy.js"; +import { createMcpRemoteTransport } from "./mcp-remote-transport.js"; + +const serviceUrl = "https://service.example/mcp"; + +for (const transportKind of ["http", "sse"] as const) { + test(`${transportKind}: real SDK discovery and exchange isolate service headers and preserve OAuth`, async () => { + const calls: { url: string; headers: Headers }[] = []; + let tokens: OAuthTokens | undefined; + let redirected = false; + const provider: OAuthClientProvider = { + get redirectUrl() { return "http://127.0.0.1:41432/callback"; }, + get clientMetadata() { return { redirect_uris: ["http://127.0.0.1:41432/callback"] }; }, + clientInformation: () => ({ client_id: "test-client" }), + tokens: () => tokens, + saveTokens: (value) => { tokens = value; }, + redirectToAuthorization: () => { redirected = true; }, + saveCodeVerifier: () => {}, + codeVerifier: () => "test-verifier", + }; + const fake: typeof fetch = async (input, init) => { + const url = String(input); + const headers = new Headers(init?.headers); + calls.push({ url, headers }); + assert.equal(init?.redirect, "error"); + if (url === serviceUrl) { + assert.equal(headers.get("x-service-key"), "synthetic-marker"); + return new Response(null, { status: 401, headers: { + "www-authenticate": 'Bearer resource_metadata="https://service.example/.well-known/oauth-protected-resource"', + } }); + } + if (url.includes("oauth-protected-resource")) { + return Response.json({ resource: serviceUrl, authorization_servers: ["https://auth.example"] }); + } + assert.equal(headers.get("x-service-key"), null, url); + if (url === "https://auth.example/token") { + return Response.json({ access_token: "test-oauth-token", token_type: "Bearer" }); + } + return Response.json({ issuer: "https://auth.example", authorization_endpoint: "https://auth.example/authorize", + token_endpoint: "https://auth.example/token", response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"] }); + }; + const transport = createMcpRemoteTransport({ transport: transportKind, serviceUrl, + serviceHeaders: { "X-Service-Key": "synthetic-marker" }, authProvider: provider, fetch: fake }); + const client = new Client({ name: "test", version: "1" }); + try { + await assert.rejects(client.connect(transport), UnauthorizedError); + assert.equal(redirected, true); + // HTTP connect already closes on auth redirection. Explicit close also + // covers SSE; exchange must retain metadata but start a fresh phase. + await client.close(); + await transport.finishAuth("test-code"); + assert.equal(tokens?.access_token, "test-oauth-token"); + assert.ok(calls.some(({ url }) => url === "https://auth.example/token")); + assert.ok(calls.some(({ url }) => url.includes("auth.example/.well-known"))); + } finally { await client.close(); } + }); +} + +test("origin matching and case-insensitive SDK headers take precedence, also for Request inputs", async () => { + const calls: Headers[] = []; + const fetch = createMcpFetchPolicy({ serviceUrl, serviceHeaders: { Authorization: "configured", "X-Key": "marker" }, + fetch: async (_input, init) => { calls.push(new Headers(init?.headers)); return Response.json({}); } }); + await (await fetch(new Request(serviceUrl, { headers: { authorization: "Bearer oauth" } }))).text(); + await (await fetch("https://service.example:444/token", { headers: { Authorization: "Basic client" } })).text(); + assert.equal(calls[0].get("authorization"), "Bearer oauth"); + assert.equal(calls[0].get("x-key"), "marker"); + assert.equal(calls[1].get("x-key"), null); + assert.equal(calls[1].get("authorization"), "Basic client"); +}); + +test("configured credentials never follow a real cross-origin HTTP redirect", async (t) => { + const { createServer } = await import("node:http"); + let destinationCalls = 0; + const destination = createServer((_req, res) => { destinationCalls++; res.end("unexpected"); }); + const source = createServer((_req, res) => { + res.writeHead(302, { Location: `http://127.0.0.1:${(destination.address() as { port: number }).port}/token` }); res.end(); + }); + for (const server of [destination, source]) { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => { server.closeAllConnections(); server.close(); }); + } + const url = `http://127.0.0.1:${(source.address() as { port: number }).port}/mcp`; + await assert.rejects(createMcpFetchPolicy({ serviceUrl: url, serviceHeaders: { "X-Key": "marker" } })(url)); + assert.equal(destinationCalls, 0); +}); + +for (const phase of ["discovery", "registration", "token", "refresh"]) { + test(`OAuth ${phase} request aborts at its own deadline`, async () => { + let aborted = false; + const fetch = createMcpFetchPolicy({ serviceUrl, oauthTimeoutMs: 10, + fetch: async (_url, init) => new Promise((_resolve, reject) => { + init!.signal!.addEventListener("abort", () => { aborted = true; reject(init!.signal!.reason); }, { once: true }); + }) }); + await assert.rejects(fetch(`https://auth.example/${phase}`), /timed out/); + assert.equal(aborted, true); + }); +} + +test("OAuth deadline includes a stalled body, while an idle MCP SSE connection stays open", async () => { + const fetch = createMcpFetchPolicy({ serviceUrl, oauthTimeoutMs: 10, + fetch: async (url) => new Response(new ReadableStream(), { headers: { + "content-type": String(url) === serviceUrl ? "text/event-stream" : "application/json", + } }) }); + await assert.rejects((await fetch("https://auth.example/token")).text(), /timed out/); + const response = await fetch(serviceUrl, { headers: { accept: "text/event-stream" } }); + const reader = response.body!.getReader(); + let settled = false; + const reading = reader.read().then(() => { settled = true; }); + await delay(25); + assert.equal(settled, false); + await reader.cancel(); + await reading; +}); + +test("owner cancellation wins and transport close aborts in-flight OAuth requests", async () => { + const owner = new AbortController(); + const fetch = createMcpFetchPolicy({ serviceUrl, signal: owner.signal, oauthTimeoutMs: 1000, + fetch: async (_url, init) => new Promise((_resolve, reject) => { + init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }); + }) }); + const pending = fetch("https://auth.example/token"); + owner.abort(new Error("owner changed")); + await assert.rejects(pending, /owner changed/); + await assert.rejects(fetch("https://auth.example/token"), /owner changed/); +}); + +function stream(chunks: string[], contentType = "application/json", cancel?: () => void) { + return new Response(new ReadableStream({ + pull(controller) { + const value = chunks.shift(); + if (value === undefined) controller.close(); + else controller.enqueue(new TextEncoder().encode(value)); + }, cancel, + }, { highWaterMark: 0 }), { headers: { "content-type": contentType } }); +} + +test("finite bodies are bounded without Content-Length and oversized declarations are cancelled", async () => { + let cancelled = false; + const fetch = createMcpFetchPolicy({ serviceUrl, maximumBytes: 16, + fetch: async () => stream(["x".repeat(12), "y".repeat(12)], "application/json", () => { cancelled = true; }) }); + await assert.rejects((await fetch(serviceUrl)).text(), /transport limit/); + assert.equal(cancelled, true); + const declared = createMcpFetchPolicy({ serviceUrl, maximumBytes: 16, + fetch: async () => new Response("x", { headers: { "content-length": "17" } }) }); + await assert.rejects(declared(serviceUrl), /transport limit/); +}); + +test("SSE bounds span chunks and lines but reset between events, including split CRLF", async () => { + for (const separator of ["\n\n", "\r\n\r\n", "\r\r"]) { + const events = Array.from({ length: 30 }, () => `data: a${separator}`).join(""); + const fetch = createMcpFetchPolicy({ serviceUrl, maximumBytes: 16, + fetch: async () => stream([...events], "text/event-stream") }); + assert.equal(await (await fetch(serviceUrl, { headers: { accept: "text/event-stream" } })).text(), events); + } + const fetch = createMcpFetchPolicy({ serviceUrl, maximumBytes: 16, + fetch: async () => stream(["data: ab\n", "data: cd\n", "data: ef\n\n"], "text/event-stream") }); + await assert.rejects((await fetch(serviceUrl, { headers: { accept: "text/event-stream" } })).text(), /transport limit/); +}); + +test("stale connections fail before dispatch", async () => { + let calls = 0; + const fetch = createMcpFetchPolicy({ serviceUrl, isCurrent: () => false, + fetch: async () => { calls++; return Response.json({}); } }); + await assert.rejects(fetch(serviceUrl), /no longer current/); + assert.equal(calls, 0); +}); + +test("OAuth JSON cannot evade aggregate bounds by advertising SSE and inserting blank lines", async () => { + const fetch = createMcpFetchPolicy({ serviceUrl, maximumBytes: 16, + fetch: async () => stream(["\n\n".repeat(1000), "{}"], "text/event-stream") }); + await assert.rejects((await fetch("https://auth.example/token")).json(), /transport limit/); +}); + +test("same-path OAuth form POST has a deadline even when token and MCP endpoints coincide", async () => { + const owner = new AbortController(); + const fetch = createMcpFetchPolicy({ serviceUrl, signal: owner.signal, oauthTimeoutMs: 10, + fetch: async (_url, init) => new Promise((_resolve, reject) => { + init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }); + }) }); + const safety = setTimeout(() => owner.abort(new Error("test safety timeout")), 100); + try { + await assert.rejects(fetch(serviceUrl + "?operation=token", { + method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "grant_type=authorization_code&code=synthetic", + }), /authorization request timed out/); + } finally { clearTimeout(safety); } +}); + +test("MCP JSON errors advertised as SSE still have a cumulative body bound", async () => { + const fetch = createMcpFetchPolicy({ serviceUrl, maximumBytes: 16, + fetch: async () => new Response("\n\n".repeat(1000) + "{}", { + status: 400, headers: { "content-type": "text/event-stream" }, + }) }); + await assert.rejects((await fetch(serviceUrl, { headers: { accept: "text/event-stream" } })).text(), /transport limit/); +}); + +test("real SDK metadata discovery keeps its deadline despite MCP-Protocol-Version headers", async () => { + for (const discover of [ + (fetchFn: typeof fetch) => discoverAuthorizationServerMetadata("https://auth.example", { fetchFn }), + (fetchFn: typeof fetch) => discoverOAuthProtectedResourceMetadata(new URL(serviceUrl), undefined, fetchFn), + ]) { + const owner = new AbortController(); + const fetch = createMcpFetchPolicy({ serviceUrl, signal: owner.signal, oauthTimeoutMs: 10, + fetch: async (_url, init) => { + assert.ok(new Headers(init?.headers).has("mcp-protocol-version")); + return new Promise((_resolve, reject) => { + init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }); + }); + } }); + const safety = setTimeout(() => owner.abort(new Error("test safety timeout")), 100); + try { await assert.rejects(discover(fetch), /authorization request timed out/); } + finally { clearTimeout(safety); } + } +}); + +test("MCP RPC retains its SDK deadline instead of the shorter OAuth timeout", async () => { + const fetch = createMcpFetchPolicy({ serviceUrl, oauthTimeoutMs: 10, + fetch: async () => { await delay(25); return Response.json({ ok: true }); } }); + const response = await fetch(new Request(serviceUrl, { method: "POST", + headers: { "mcp-protocol-version": "2025-11-25", "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call" }), + })); + assert.deepEqual(await response.json(), { ok: true }); +}); + +test("SSE initialize JSON-RPC without a protocol header retains the SDK deadline", async () => { + const fetch = createMcpFetchPolicy({ serviceUrl, oauthTimeoutMs: 10, + fetch: async () => { await delay(25); return Response.json({ jsonrpc: "2.0", id: 1, result: {} }); } }); + const response = await fetch(serviceUrl, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + }); + assert.equal(response.status, 200); +}); + +test("same-origin OAuth registration JSON still receives the authorization deadline", async () => { + const fetch = createMcpFetchPolicy({ serviceUrl, oauthTimeoutMs: 10, + fetch: async (_url, init) => new Promise((_resolve, reject) => { + init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }); + }) }); + await assert.rejects(fetch(serviceUrl, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ redirect_uris: ["http://127.0.0.1/callback"] }), + }), /authorization request timed out/); +}); + +test("real SDK authorization metadata body cannot stall after headers arrive", async () => { + const fetchFn = createMcpFetchPolicy({ serviceUrl, oauthTimeoutMs: 10, + fetch: async () => new Response(new ReadableStream(), { headers: { "content-type": "application/json" } }) }); + await assert.rejects(discoverAuthorizationServerMetadata("https://auth.example", { fetchFn }), /timed out/); +}); diff --git a/main/services/mcp-fetch-policy.ts b/main/services/mcp-fetch-policy.ts new file mode 100644 index 00000000..25f353af --- /dev/null +++ b/main/services/mcp-fetch-policy.ts @@ -0,0 +1,175 @@ +export const MAX_MCP_RESPONSE_BYTES = 8 * 1024 * 1024; +export const MCP_OAUTH_REQUEST_TIMEOUT_MS = 30_000; + +const OVERSIZED = "MCP response exceeded the transport limit."; +const TIMED_OUT = "MCP authorization request timed out. Try connecting again."; + +function isJsonRpcBody(body: BodyInit | null | undefined): boolean { + if (typeof body !== "string") return false; + try { + const value: unknown = JSON.parse(body); + const isMessage = (entry: unknown) => entry !== null && typeof entry === "object" && + (entry as { jsonrpc?: unknown }).jsonrpc === "2.0"; + return Array.isArray(value) ? value.length > 0 && value.every(isMessage) : isMessage(value); + } catch { + return false; + } +} + +/** Count SSE frames, not the lifetime of an intentionally long-lived stream. */ +function frameCounter(maximumBytes: number) { + let frameBytes = 0; + let lineBytes = 0; + let previousCR = false; + return (chunk: Uint8Array) => { + for (const byte of chunk) { + frameBytes += 1; + if (frameBytes > maximumBytes) throw new Error(OVERSIZED); + if (previousCR && byte === 10) { + previousCR = false; + continue; + } + previousCR = byte === 13; + if (byte === 10 || byte === 13) { + if (lineBytes === 0) frameBytes = 0; + lineBytes = 0; + } else { + lineBytes += 1; + } + } + }; +} + +export interface McpFetchPolicyOptions { + serviceUrl: string; + serviceHeaders?: Record; + signal?: AbortSignal; + isCurrent?: () => boolean; + fetch?: typeof globalThis.fetch; + maximumBytes?: number; + oauthTimeoutMs?: number; +} + +/** + * Service credentials are not SDK-wide RequestInit defaults: OAuth discovery + * may legitimately leave the service origin. SDK request headers win, including + * its own Authorization header. Redirects cannot carry either set elsewhere. + */ +export function createMcpFetchPolicy(options: McpFetchPolicyOptions): typeof fetch { + const service = new URL(options.serviceUrl); + const configuredHeaders = new Headers(options.serviceHeaders); + const fetchImpl = options.fetch ?? globalThis.fetch; + const maximumBytes = options.maximumBytes ?? MAX_MCP_RESPONSE_BYTES; + const timeoutMs = options.oauthTimeoutMs ?? MCP_OAUTH_REQUEST_TIMEOUT_MS; + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || + !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) { + throw new Error("Invalid MCP transport policy."); + } + return async (input, init) => { + const request = input instanceof Request ? input : undefined; + const url = new URL(request?.url ?? String(input)); + const requestHeaders = new Headers(init?.headers ?? request?.headers); + const headers = new Headers(url.origin === service.origin ? configuredHeaders : undefined); + requestHeaders.forEach((value, name) => headers.set(name, value)); + // MCP calls already have SDK request deadlines; SSE notifications may stay + // idle indefinitely. OAuth metadata/registration/token requests do not. + const acceptsSse = (requestHeaders.get("accept") ?? "").includes("text/event-stream"); + const method = (init?.method ?? request?.method ?? "GET").toUpperCase(); + const contentType = (requestHeaders.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase(); + // Metadata GETs also carry MCP-Protocol-Version. SSE initialize sends a + // JSON-RPC body before that header is available, while OAuth registration + // is JSON without a JSON-RPC envelope and token requests are form POSTs. + const mcpRequest = acceptsSse || (method === "POST" && contentType === "application/json" && + (requestHeaders.has("mcp-protocol-version") || isJsonRpcBody(init?.body))); + const controller = new AbortController(); + const signal = AbortSignal.any([ + controller.signal, + ...(options.signal ? [options.signal] : []), + ...(init?.signal ? [init.signal] : request ? [request.signal] : []), + ]); + let timer: ReturnType | undefined; + let reader: ReadableStreamDefaultReader | undefined; + let bodyController: ReadableStreamDefaultController | undefined; + let finished = false; + const cleanup = () => { + finished = true; + clearTimeout(timer); + signal.removeEventListener("abort", aborted); + }; + const aborted = () => { + if (finished) return; + cleanup(); + void reader?.cancel(signal.reason).catch(() => undefined); + bodyController?.error(signal.reason); + }; + const assertCurrent = () => { + signal.throwIfAborted(); + if (options.isCurrent && !options.isCurrent()) { + throw new Error("MCP connection is no longer current."); + } + }; + if (!mcpRequest) timer = setTimeout(() => controller.abort(new Error(TIMED_OUT)), timeoutMs); + signal.addEventListener("abort", aborted, { once: true }); + try { + assertCurrent(); + const response = await fetchImpl(input, { ...init, headers, signal, redirect: "error" }); + assertCurrent(); + // OAuth consumes JSON regardless of a server's advertised MIME type. + // Only successful SDK SSE requests get a per-frame (not total) budget. + const sse = acceptsSse && response.ok && (response.headers.get("content-type") ?? "") + .split(";", 1)[0].trim().toLowerCase() === "text/event-stream"; + const declared = response.headers.get("content-length"); + if (!sse && declared !== null && + (!/^\d+$/u.test(declared) || !Number.isSafeInteger(Number(declared)) || Number(declared) > maximumBytes)) { + void response.body?.cancel(OVERSIZED).catch(() => undefined); + throw new Error(OVERSIZED); + } + if (!response.body) { + cleanup(); + return response; + } + reader = response.body.getReader(); + const checkFrame = frameCounter(maximumBytes); + let observed = 0; + const body = new ReadableStream({ + start(streamController) { bodyController = streamController; }, + async pull(streamController) { + try { + assertCurrent(); + const next = await reader!.read(); + if (finished) return; + assertCurrent(); + if (next.done) { + cleanup(); + streamController.close(); + return; + } + if (sse) checkFrame(next.value); + else { + observed += next.value.byteLength; + if (observed > maximumBytes) throw new Error(OVERSIZED); + } + streamController.enqueue(next.value); + } catch (error) { + if (finished) return; + cleanup(); + void reader!.cancel(error).catch(() => undefined); + streamController.error(error); + } + }, + cancel(reason) { + cleanup(); + return reader!.cancel(reason); + }, + }); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } catch (error) { + cleanup(); + throw error; + } + }; +} diff --git a/main/services/mcp-oauth.ts b/main/services/mcp-oauth.ts index 76d06dfe..dd280d59 100644 --- a/main/services/mcp-oauth.ts +++ b/main/services/mcp-oauth.ts @@ -6,8 +6,8 @@ import * as http from "http"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import type { @@ -42,6 +42,7 @@ import { assertMcpPresetServer, mcpOAuthClientNameForServer } from "./mcp-preset import { closeAgainAfterSettled } from "./generation-bound-connection-cache.js"; import type { McpServer } from "./types.js"; import { withMcpConfigurationPublication } from "./mcp-config-lease.js"; +import { createMcpRemoteTransport } from "./mcp-remote-transport.js"; // Fixed loopback redirect so the registered redirect_uri stays stable across // sessions (dynamic client registration records it once). @@ -208,17 +209,17 @@ class McpOAuthProvider implements OAuthClientProvider { export function makeOAuthTransport( server: McpServer, provider: OAuthClientProvider, + signal?: AbortSignal, ): StreamableHTTPClientTransport | SSEClientTransport { assertMcpPresetServer(server); if (!server.url) throw new Error("This MCP server needs a URL."); - const url = new URL(server.url); - const requestInit = server.headers ? { headers: server.headers } : undefined; - if (server.transport === "sse") { - return new SSEClientTransport(url, { authProvider: provider, requestInit }); - } - return new StreamableHTTPClientTransport(url, { + if (server.transport === "stdio") throw new Error("OAuth requires a remote MCP server."); + return createMcpRemoteTransport({ + transport: server.transport, + serviceUrl: server.url, + serviceHeaders: server.headers, + signal, authProvider: provider, - requestInit, }); } @@ -445,7 +446,11 @@ export async function authorizeMcpServer( // while the SDK mutates a private replacement buffer. A renderer reload, // failed provider, or process crash before final verification cannot erase // credentials that were still valid when the user started. - const transport = makeOAuthTransport(server, provider); + const transportSignal = AbortSignal.any([ + operation.signal, + ...(ownerSignal ? [ownerSignal] : []), + ]); + const transport = makeOAuthTransport(server, provider, transportSignal); const client = new Client( { name: "aiden-agent", version: "0.27.0" }, { capabilities: {} }, @@ -470,17 +475,21 @@ export async function authorizeMcpServer( // Expected: connect() triggered redirectToAuthorization (browser opened). } - const code = await raceMcpOAuthCancellation(loopback.waitForCode(), [ - operation.signal, - ownerSignal, - ]); - await raceMcpOAuthCancellation(transport.finishAuth(code), [ - operation.signal, - ownerSignal, - ]); + try { + const code = await raceMcpOAuthCancellation(loopback.waitForCode(), [ + operation.signal, + ownerSignal, + ]); + await raceMcpOAuthCancellation(transport.finishAuth(code), [ + operation.signal, + ownerSignal, + ]); + } finally { + await transport.close().catch(() => {}); + } // Verify the freshly minted tokens actually authorize a connection. - const verifyTransport = makeOAuthTransport(server, provider); + const verifyTransport = makeOAuthTransport(server, provider, transportSignal); const verifyClient = new Client( { name: "aiden-agent", version: "0.27.0" }, { capabilities: {} }, diff --git a/main/services/mcp-remote-transport.ts b/main/services/mcp-remote-transport.ts new file mode 100644 index 00000000..fa77d943 --- /dev/null +++ b/main/services/mcp-remote-transport.ts @@ -0,0 +1,39 @@ +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import { createMcpFetchPolicy, type McpFetchPolicyOptions } from "./mcp-fetch-policy.js"; + +/** Shared by attended sign-in, stored-token connections, and isolated children. */ +export function createMcpRemoteTransport( + options: McpFetchPolicyOptions & { + transport: "http" | "sse"; + authProvider?: OAuthClientProvider; + }, +) { + let lifetime = new AbortController(); + const fetch: typeof globalThis.fetch = (input, init) => createMcpFetchPolicy({ + ...options, + signal: options.signal + ? AbortSignal.any([lifetime.signal, options.signal]) + : lifetime.signal, + })(input, init); + const transportOptions = { fetch, authProvider: options.authProvider }; + const transport = options.transport === "sse" + ? new SSEClientTransport(new URL(options.serviceUrl), transportOptions) + : new StreamableHTTPClientTransport(new URL(options.serviceUrl), transportOptions); + const close = transport.close.bind(transport); + transport.close = async () => { + lifetime.abort(new Error("MCP connection closed.")); + await close(); + }; + // Client.connect closes HTTP transports when OAuth opens the browser. The + // SDK deliberately supports finishAuth on that same object afterward so it + // retains discovered resource metadata. Exchange is a new network phase, + // still fenced by the original owner signal and generation predicate. + const finishAuth = transport.finishAuth.bind(transport); + transport.finishAuth = async (code) => { + if (lifetime.signal.aborted) lifetime = new AbortController(); + await finishAuth(code); + }; + return transport; +} diff --git a/main/services/mcp-tool-result.test.ts b/main/services/mcp-tool-result.test.ts index ef4ac3bd..ecb39deb 100644 --- a/main/services/mcp-tool-result.test.ts +++ b/main/services/mcp-tool-result.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { assertUniqueMcpAgentToolNames, mcpAgentToolName } from "./mcp-tool-identity.js"; -import { executeMcpAgentTool, mcpAgentToolResult } from "./mcp-tool-result.js"; +import { executeMcpAgentTool, mcpAgentToolResult, MAX_MCP_RESULT_TEXT_CHARS } from "./mcp-tool-result.js"; test("MCP agent tool names bind to stable server and raw tool identities", () => { const first = mcpAgentToolName({ id: "github-work", name: "GitHub" }, "create_issue"); @@ -41,3 +41,76 @@ test("throws a standard resolved MCP isError result so Pi records tool failure", /permission denied/u, ); }); + +function text(result: unknown): string { + const part = mcpAgentToolResult(result).content[0]; + assert.equal(part.type, "text"); + return part.type === "text" ? part.text : ""; +} + +test("mixed MCP evidence retains text and structured content with explicit media omissions", () => { + const result = text({ content: [{ type: "text", text: "evidence" }, + { type: "image", mimeType: "image/png", data: "PRIVATE_PIXELS" }, + { type: "audio", data: "PRIVATE_AUDIO" }, + { type: "resource_link", uri: "https://private.example" }], structuredContent: { answer: 42 } }); + assert.match(result, /evidence/); + assert.match(result, /"answer":42/); + assert.match(result, /image omitted/); + assert.match(result, /audio omitted/); + assert.match(result, /no resource was fetched/); + assert.doesNotMatch(result, /PRIVATE_|private.example/); +}); + +test("binary-only results never stringify the protocol envelope", () => { + const result = text({ content: [{ type: "image", data: "A".repeat(1_000_000) }] }); + assert.ok(result.length < 100); + assert.match(result, /image omitted/); + assert.doesNotMatch(result, /AAAA/); +}); + +test("every result is bounded before compaction pressure, including error and multipart output", () => { + for (const content of [[{ type: "text", text: "x".repeat(1_000_000) }], + Array.from({ length: 1000 }, () => ({ type: "text", text: "x".repeat(1000) }))]) { + const result = text({ content }); + assert.ok(result.length <= MAX_MCP_RESULT_TEXT_CHARS); + assert.match(result, /truncated/); + assert.throws(() => mcpAgentToolResult({ content, isError: true }), (error: unknown) => + error instanceof Error && error.message.length <= MAX_MCP_RESULT_TEXT_CHARS && /truncated/.test(error.message)); + } +}); + +test("structured-only output is bounded for large, deep, cyclic, and non-JSON values", () => { + const cycle: Record = { answer: 42 }; + cycle.self = cycle; + for (const structuredContent of [cycle, { value: "x".repeat(1_000_000) }, + { values: Array.from({ length: 10000 }, () => ({ value: "x".repeat(1000) })) }, + { value: BigInt(42) }, { get value() { throw new Error("accessor must not run"); } }]) { + const result = text({ structuredContent }); + assert.ok(result.length <= MAX_MCP_RESULT_TEXT_CHARS); + assert.match(result, /Structured content/); + assert.match(result, /omitted|truncated|unsupported/); + } +}); + +test("invalid blocks, unknown binary envelopes, and empty output have closed projections", () => { + assert.match(text({ content: [null, { type: "unknown", data: "SECRET" }] }), /omitted/); + assert.doesNotMatch(text({ content: [null, { type: "unknown", data: "SECRET" }] }), /SECRET/); + assert.equal(text({ blob: "SECRET" }), "MCP tool returned no result."); + assert.equal(text({ content: [] }), "MCP tool returned no result."); + assert.throws(() => text(null), /invalid result/); +}); + +test("structured field limits never rename colliding keys or overwrite a real omission-named field", () => { + const prefix = "k".repeat(128); + const longKeys = text({ structuredContent: { + [prefix + "a"]: "alpha", [prefix + "b"]: "beta", keep: "visible", + } }); + assert.match(longKeys, /visible/); + assert.doesNotMatch(longKeys, new RegExp(prefix)); + assert.match(longKeys, /fields omitted/); + const fields: Record = { "[omitted]": "KEEP_REAL_VALUE" }; + for (let index = 0; index < 40; index++) fields[`key${index}`] = "value"; + const result = text({ structuredContent: fields }); + assert.match(result, /KEEP_REAL_VALUE/); + assert.match(result, /fields omitted/); +}); diff --git a/main/services/mcp-tool-result.ts b/main/services/mcp-tool-result.ts index 53d712bb..f34439e1 100644 --- a/main/services/mcp-tool-result.ts +++ b/main/services/mcp-tool-result.ts @@ -1,17 +1,106 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +export const MAX_MCP_RESULT_TEXT_CHARS = 32_000; +const MAX_PARTS = 64; +const OMITTED = "\n[MCP result truncated; additional content omitted.]"; + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Project JSON with bounded work before stringifying; never serialize an unknown envelope. */ +function structuredText(value: unknown): string { + let nodes = 0; + let remaining = 8_000; + let fieldsOmitted = false; + const seen = new Set(); + const project = (entry: unknown, depth: number): unknown => { + if (++nodes > 256 || depth > 6 || remaining <= 0) return "[omitted: limit]"; + if (typeof entry === "string") { + const limit = Math.min(remaining, 2_000); + const text = entry.slice(0, limit); + remaining -= text.length; + return entry.length > limit ? `${text}[truncated]` : text; + } + if (entry === null || typeof entry === "boolean") return entry; + if (typeof entry === "number") return Number.isFinite(entry) ? entry : "[invalid number]"; + if (typeof entry !== "object") return "[unsupported value]"; + if (seen.has(entry)) return "[omitted: cycle]"; + seen.add(entry); + if (Array.isArray(entry)) { + const output: unknown[] = []; + for (let index = 0; index < Math.min(entry.length, 32); index += 1) { + if (nodes >= 256 || remaining <= 0) break; + output.push(project(entry[index], depth + 1)); + } + if (output.length < entry.length) output.push("[omitted: items]"); + seen.delete(entry); + return output; + } + const output: Record = Object.create(null); + let count = 0; + for (const key in entry) { + if (!Object.prototype.hasOwnProperty.call(entry, key)) continue; + if (count++ >= 32 || nodes >= 256 || remaining <= 0) { + fieldsOmitted = true; + break; + } + if (key.length > 128) { + fieldsOmitted = true; + continue; + } + const descriptor = Object.getOwnPropertyDescriptor(entry, key); + remaining -= key.length; + output[key] = descriptor && "value" in descriptor + ? project(descriptor.value, depth + 1) + : "[unsupported accessor]"; + } + seen.delete(entry); + return output; + }; + const json = JSON.stringify(project(value, 0)); + return json + (fieldsOmitted ? "\n[Structured fields omitted: key or field limit.]" : ""); +} + function toText(result: unknown): string { - const candidate = result as { - content?: Array<{ type?: string; text?: string }>; + if (!record(result)) throw new Error("MCP tool returned an invalid result."); + const parts: string[] = []; + let remaining = MAX_MCP_RESULT_TEXT_CHARS - OMITTED.length; + let truncated = false; + const append = (text: string) => { + const separator = parts.length ? "\n" : ""; + const limit = Math.max(0, remaining - separator.length); + if (text.length > limit) truncated = true; + if (limit > 0 && text.length) { + const part = separator + text.slice(0, limit); + parts.push(part); + remaining -= part.length; + } }; - if (Array.isArray(candidate?.content)) { - const text = candidate.content - .map((content) => (content.type === "text" ? content.text : undefined)) - .filter((value): value is string => Boolean(value)) - .join("\n"); - if (text) return text; + // Reserve space for structured evidence even when the text part is huge. + const structured = result.structuredContent === undefined + ? undefined : `Structured content:\n${structuredText(result.structuredContent)}`; + if (structured) { + const limit = 16_000; + append(structured.length > limit ? structured.slice(0, limit) + "\n[Structured content truncated.]" : structured); + } + if (Array.isArray(result.content)) { + for (let index = 0; index < Math.min(result.content.length, MAX_PARTS); index += 1) { + const part: unknown = result.content[index]; + if (!record(part)) append("[Invalid MCP content block omitted.]"); + else if (part.type === "text" && typeof part.text === "string") append(part.text); + else if (part.type === "image") append("[MCP image omitted: this tool result supports text only.]"); + else if (part.type === "audio") append("[MCP audio omitted: this tool result supports text only.]"); + else if (part.type === "resource" || part.type === "resource_link") { + append("[MCP resource omitted: no resource was fetched.]"); + } else append("[Unsupported MCP content block omitted.]"); + if (remaining <= 0) { truncated = true; break; } + } + if (result.content.length > MAX_PARTS) truncated = true; + } else if (result.content !== undefined) { + append("[Invalid MCP content list omitted.]"); } - return JSON.stringify(result) || "MCP tool returned no result."; + return (parts.join("") || "MCP tool returned no result.") + (truncated ? OMITTED : ""); } /** diff --git a/main/services/mcp.ts b/main/services/mcp.ts index 611349f3..04891a6d 100644 --- a/main/services/mcp.ts +++ b/main/services/mcp.ts @@ -4,8 +4,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Type } from "@earendil-works/pi-ai"; import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; import { logger } from "../platform.js"; @@ -13,7 +11,6 @@ import { oauthProviderFor } from "./mcp-oauth.js"; import { mcpApiKeyHeaderValue } from "./mcp-oauth-client-metadata.js"; import { assertMcpPresetServer, - createNoRedirectFetch, presetSecretId, } from "./mcp-presets.js"; import { secrets } from "./secrets.js"; @@ -52,6 +49,8 @@ import type { SubagentMcpRemoteTool, } from "./subagents/subagent-mcp-read.js"; import { mcpConfigurationLeases } from "./mcp-config-lease.js"; +import { createMcpRemoteTransport } from "./mcp-remote-transport.js"; +import { MAX_MCP_RESPONSE_BYTES } from "./mcp-fetch-policy.js"; interface Transport { close?: () => Promise; @@ -105,6 +104,7 @@ function makeTransport( throw new Error("This MCP server needs a command to run."); return new StdioClientTransport({ command: server.command, + maxBufferSize: MAX_MCP_RESPONSE_BYTES, args: server.args ?? [], env: { ...(process.env as Record), @@ -113,14 +113,10 @@ function makeTransport( }); } if (!server.url) throw new Error("This MCP server needs a URL."); - const preset = assertMcpPresetServer(server); - const url = new URL(server.url); - const requestInit = server.headers ? { headers: server.headers } : undefined; + assertMcpPresetServer(server); const guardedFetch = options.forceNoRedirect ? createBoundedSubagentMcpFetch() - : preset?.auth.kind === "apiKey" - ? createNoRedirectFetch() - : undefined; + : undefined; // OAuth-authenticated servers attach a (non-interactive) provider that supplies // stored tokens; if none/expired, the connection fails rather than opening a browser. const observeOAuthTokens = options.registerCredentialRedactor @@ -133,15 +129,11 @@ function makeTransport( ), ) : undefined; - if (server.transport === "sse") { - return new SSEClientTransport(url, { - requestInit, - authProvider, - fetch: guardedFetch, - }); - } - return new StreamableHTTPClientTransport(url, { - requestInit, + return createMcpRemoteTransport({ + transport: server.transport, + serviceUrl: server.url, + serviceHeaders: server.headers, + isCurrent, authProvider, fetch: guardedFetch, }); diff --git a/main/services/subagents/subagent-child-runner.ts b/main/services/subagents/subagent-child-runner.ts index 85b813fa..45146c4a 100644 --- a/main/services/subagents/subagent-child-runner.ts +++ b/main/services/subagents/subagent-child-runner.ts @@ -30,7 +30,7 @@ import { type SubagentRuntimeAuthority, type SubagentRuntimeChild, } from "./child-agent-runtime.js"; -import type { SubagentReadToolName } from "./capability-profile.js"; +import { SUBAGENT_READ_TOOL_NAMES, type SubagentReadToolName } from "./capability-profile.js"; import { captureLiveSubagentContext, type SubagentContextCapture, @@ -582,9 +582,9 @@ export async function runSubagentChild(input: RunSubagentChildInput): Promise 0), + workspaceRead: childTools.some((tool) => + (SUBAGENT_READ_TOOL_NAMES as readonly string[]).includes(tool.name), + ), workspaceWrite: input.v2Authority?.capabilities.workspaceWrite === true, shell: input.v2Authority?.capabilities.shell === true, mcpRead: diff --git a/main/services/subagents/subagent-supervisor.test.ts b/main/services/subagents/subagent-supervisor.test.ts index 1d5ac8d0..b1c8a6b5 100644 --- a/main/services/subagents/subagent-supervisor.test.ts +++ b/main/services/subagents/subagent-supervisor.test.ts @@ -1562,6 +1562,45 @@ test("child runner returns only the terminal assistant answer", async () => { assert.doesNotMatch(result.summary, /INTERMEDIATE-NARRATION/u); }); +test("child workspace-read prompt follows assembled tools even when the V2 grant permits more", async (t) => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-prompt-tools-")); + t.after(() => fs.rm(workspaceRoot, { recursive: true, force: true })); + for (const inheritedCeiling of [[], ["read_file"]] as const) { + const authority = phase6Authority({ runId: "prompt-tools", contextRevision: "d".repeat(64), delegate: false }); + assert.equal(authority.capabilities.workspaceRead, true); + let prompt = ""; + const control = fakeChild(async ({ emit }) => { + const message = assistant("Analyzed the supplied task."); + await emit({ type: "message_start", message } as AgentEvent); + await emit({ type: "message_end", message } as AgentEvent); + }); + const result = await runSubagentChild({ + authority: TEST_CHILD_AUTHORITY, context: TEST_CHILD_CONTEXT, + v2Authority: authority, currentV2Authority: () => authority, + groupId: "prompt-tools", runtime: runtime(), thinkingLevel: "high", + workspaceRoot, permission: "ask", inheritedCeiling, + request: { role: "reviewer", label: "Analyze", task: "Analyze the supplied evidence." }, + dependencies: { + // This is the production workspace capability intersection, not an + // invented empty tool builder. V2 authority must never override it. + buildTools: async (input) => buildSubagentCapabilityTools({ + workspaceRoot: input.workspaceRoot, permission: input.permission, + capabilityProfile: { kind: "subagent", role: input.role, inheritedCeiling: input.inheritedCeiling }, + }).tools, + createChild: (spec) => { prompt = spec.systemPrompt; return control.child; }, + recordUsage: async () => {}, + }, + }); + assert.equal(result.status, "completed", "intentionally tool-free analysis remains valid"); + if (inheritedCeiling.length) assert.match(prompt, /You have read-only workspace tools/); + else { + assert.match(prompt, /You have no workspace read or mutation tools/); + assert.doesNotMatch(prompt, /You have read-only workspace tools/); + } + assert.equal(authority.capabilities.workspaceRead, true, "prompt correction cannot rewrite authority"); + } +}); + test("child runner rejects protocol-less and empty assistant completions", async () => { for (const control of [ fakeChild(async () => {}), diff --git a/package.json b/package.json index d66a3fc8..f2e6db89 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,9 @@ "generative-ui:vendor": "node scripts/vendor-generative-ui-libs.mjs", "pretest:generative-ui": "npm run build:subagent-file-mutator", "test:aiden-remote-speech": "tsx --test main/services/aiden-remote-speech.test.ts", - "pretest": "npm run build:worktree-remover && npm run test:browser && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:memory-policy && npm run test:ios-release && npm run test:terminal:coverage && npm run test:ghostty-terminal && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice && npm run test:sidebar", + "pretest": "npm run build:worktree-remover && npm run test:mcp && npm run test:browser && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:memory-policy && npm run test:ios-release && npm run test:terminal:coverage && npm run test:ghostty-terminal && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice && npm run test:sidebar", "pretest:coverage": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run test:preflight && npm run test:ghostty-terminal && npm run test:scheduled && npm run test:memory-policy && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:display-image && npm run test:generative-ui && npm run test:compaction && npm run test:subagents && npm run test:bots:coverage", - "test:preflight": "npm run test:artificial-analysis && npm run test:model-pad && tsx --test main/services/appearance-preview-core.test.ts main/services/generation-timeline.test.ts main/services/local-runtime-status.test.ts main/services/mcp-tool-result.test.ts main/services/pi-thinking-disclosure.integration.test.ts renderer/components/activity-feed.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/components/settings/providers-settings.test.tsx renderer/main/chat-transition.test.tsx renderer/components/reasoning-block.test.tsx renderer/components/reasoning-visibility-control.test.tsx renderer/components/thinking-control.test.tsx renderer/lib/agent-steps.test.ts renderer/lib/button-appearance-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/inline-metadata-hierarchy.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/pill-appearance.test.ts renderer/lib/reasoning-disclosure.test.ts renderer/lib/streaming-motion-contract.test.ts renderer/lib/streaming-reveal.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/pill-preload-channels.test.ts renderer/shared/anthropic-thinking.test.ts renderer/shared/app-update.test.ts renderer/shared/claim-check.test.ts renderer/shared/codex-thinking.test.ts renderer/shared/google-thinking.test.ts renderer/shared/provider-deployment.test.ts", + "test:preflight": "npm run test:mcp && npm run test:artificial-analysis && npm run test:model-pad && tsx --test main/services/appearance-preview-core.test.ts main/services/generation-timeline.test.ts main/services/local-runtime-status.test.ts main/services/mcp-tool-result.test.ts main/services/pi-thinking-disclosure.integration.test.ts renderer/components/activity-feed.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/components/settings/providers-settings.test.tsx renderer/main/chat-transition.test.tsx renderer/components/reasoning-block.test.tsx renderer/components/reasoning-visibility-control.test.tsx renderer/components/thinking-control.test.tsx renderer/lib/agent-steps.test.ts renderer/lib/button-appearance-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/inline-metadata-hierarchy.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/pill-appearance.test.ts renderer/lib/reasoning-disclosure.test.ts renderer/lib/streaming-motion-contract.test.ts renderer/lib/streaming-reveal.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/pill-preload-channels.test.ts renderer/shared/anthropic-thinking.test.ts renderer/shared/app-update.test.ts renderer/shared/claim-check.test.ts renderer/shared/codex-thinking.test.ts renderer/shared/google-thinking.test.ts renderer/shared/provider-deployment.test.ts", "test:sidebar": "tsx --test renderer/components/chat-sidebar.test.tsx renderer/lib/sidebar-workspace-groups.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts", "test:peer-hosts": "tsx --test main/services/peer-host-registry.test.ts main/services/peer-transport.test.ts", "test:aiden-remote": "npm run test:peer-hosts && tsx --test main/handlers/aiden-remote.test.ts main/services/aiden-remote-chat-summaries.test.ts main/services/aiden-remote-approved-roots.test.ts main/services/aiden-remote-revocation.test.ts main/services/aiden-remote-bot-files.test.ts main/services/aiden-remote-bots.test.ts main/services/aiden-remote-chat-http.test.ts main/services/aiden-remote-chats.test.ts main/services/aiden-remote-files.test.ts main/services/aiden-remote-git.test.ts main/services/aiden-remote-models.test.ts main/services/aiden-remote-protocol.test.ts main/services/aiden-remote-opaque-handles.test.ts main/services/aiden-remote-operation-contract.test.ts main/services/aiden-remote-pairing.test.ts main/services/aiden-remote-ports.test.ts main/services/aiden-remote-router.test.ts main/services/aiden-remote-schedules.test.ts main/services/aiden-remote-service.test.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-streams.test.ts main/services/aiden-remote-tailscale-route.test.ts main/services/aiden-remote-tailscale.test.ts main/services/aiden-remote-tls-identity.test.ts main/services/aiden-remote-workspace-browser.test.ts main/services/aiden-remote-workspace-http.test.ts main/services/aiden-remote-workspaces.test.ts renderer/components/remote-connection-popover.test.tsx renderer/components/settings/remote-access-settings.test.tsx renderer/lib/remote-approval.test.ts renderer/lib/remote-connection-status.test.ts renderer/lib/remote-pairing-lifecycle.test.ts renderer/lib/settings-section.test.ts && node --test scripts/aiden-remote-lan-transport-spike.test.mjs", @@ -72,6 +72,7 @@ "test:advisor": "tsx --test renderer/shared/advisor.test.ts main/services/advisor-context.test.ts main/services/advisor-attempt-store.test.ts main/services/advisor-runtime.test.ts main/services/advisor-integration.test.ts", "test:generative-ui": "tsx --test main/services/generative-ui-html.test.ts main/services/generative-ui-extension.test.ts main/services/generative-ui-artifact-store.test.ts main/services/generative-ui-host-libraries.test.ts main/services/generative-ui-protocol.test.ts renderer/shared/chat-artifacts.test.ts renderer/shared/generative-ui.test.ts && node --test scripts/vendor-generative-ui-libs.test.mjs && playwright test --config=playwright.generative-ui.config.ts --fail-on-flaky-tests", "test:google-provider": "tsx --test main/services/anthropic-provider.test.ts main/services/google-provider.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/provider-config-migration-core.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/schedule-store.test.ts renderer/lib/google-provider-migration.test.ts", + "test:mcp": "tsx --test main/services/mcp-fetch-policy.test.ts main/services/mcp-tool-result.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-credential-cleanup-core.test.ts main/services/subagents/subagent-mcp-bounded-fetch.test.ts", "test:config-recovery": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/services/secret-map-core.test.ts main/services/provider-credential-rotation-core.test.ts main/services/legacy-pi-credential-migration-core.test.ts main/services/mcp-credential-cleanup-core.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-oauth-store-core.test.ts", "pretest:subagents": "npm run build:worktree-remover && npm run build:subagent-run-store && node scripts/build-subagent-run-store.mjs --test && npm run build:subagent-file-mutator && node scripts/build-subagent-file-mutator.mjs --test && npm run build:subagent-shell-runner && node scripts/build-subagent-shell-runner.mjs --test && npm run test:subagents:inventory && npm run test:subagents:workspace-write && npm run test:subagents:phase5a && npm run test:subagents:phase5b && npm run test:subagents:phase5c && npm run test:subagents:phase5d && npm run test:subagents:phase5e && npm run test:subagents:phase6a && npm run test:subagents:phase6b && npm run test:subagents:phase7a && npm run test:subagents:soak:contracts", "test:subagents:inventory": "tsx --test main/services/subagents/subagent-mcp-inventory-core.test.ts main/services/subagents/subagent-inference-process-core.test.ts",