From 566d15e61fe531082e6390a8944f1f762d68afbd Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 10 Jul 2026 01:19:21 -0400 Subject: [PATCH 1/9] refactor(core): make Media results host-aware Unify HTTP-like inline, local-artifact, and remote-reference results across progressive, direct, native, and hosted adapters. Preserve mixed MCP content, enforce GraphQL artifact bounds, and keep filesystem paths behind explicit host capability. --- .changeset/calm-media-seam.md | 8 + .../docs/src/content/docs/troubleshooting.mdx | 15 +- docs/architecture.md | 4 + ...tor-caplets-architecture-deepening-plan.md | 667 ++++++++++++++++++ packages/core/src/caplet-sets.ts | 8 + packages/core/src/engine.ts | 16 +- packages/core/src/google-discovery/manager.ts | 7 + .../core/src/google-discovery/operations.ts | 5 +- packages/core/src/graphql.ts | 555 ++++++++++++++- packages/core/src/http-actions.ts | 11 +- packages/core/src/http/response.ts | 183 +++-- packages/core/src/index.ts | 2 +- packages/core/src/media/artifacts.ts | 250 ++++++- packages/core/src/media/index.ts | 1 + packages/core/src/media/input.ts | 7 +- packages/core/src/media/results.ts | 133 ++++ packages/core/src/openapi.ts | 9 +- packages/core/src/result-content.ts | 40 +- packages/core/src/runtime.ts | 8 + packages/core/src/serve/http.ts | 37 +- packages/core/src/serve/index.ts | 22 +- packages/core/src/serve/session.ts | 54 +- packages/core/src/tools.ts | 81 ++- packages/core/test/attach-api.test.ts | 111 ++- packages/core/test/caplet-sets.test.ts | 143 +++- .../fixtures/stdio-mixed-content-server.ts | 30 + packages/core/test/google-discovery.test.ts | 56 +- packages/core/test/graphql.test.ts | 411 ++++++++++- packages/core/test/http-actions.test.ts | 235 ++++-- packages/core/test/mcp-test-client.ts | 42 ++ packages/core/test/media-artifacts.test.ts | 199 +++++- packages/core/test/native.test.ts | 94 ++- packages/core/test/openapi.test.ts | 58 +- packages/core/test/result-content.test.ts | 15 +- packages/core/test/runtime.test.ts | 113 ++- packages/core/test/serve-http.test.ts | 362 +++++++++- packages/core/test/serve-session.test.ts | 304 +++++++- packages/core/test/tools.test.ts | 116 ++- packages/pi/src/index.ts | 42 +- packages/pi/test/pi.test.ts | 48 ++ 40 files changed, 4099 insertions(+), 403 deletions(-) create mode 100644 .changeset/calm-media-seam.md create mode 100644 docs/plans/2026-07-09-001-refactor-caplets-architecture-deepening-plan.md create mode 100644 packages/core/src/media/results.ts create mode 100644 packages/core/test/fixtures/stdio-mixed-content-server.ts create mode 100644 packages/core/test/mcp-test-client.ts diff --git a/.changeset/calm-media-seam.md b/.changeset/calm-media-seam.md new file mode 100644 index 00000000..eeac3bb5 --- /dev/null +++ b/.changeset/calm-media-seam.md @@ -0,0 +1,8 @@ +--- +"@caplets/core": minor +"@caplets/pi": patch +--- + +Unify HTTP-like non-inline results behind explicit local-artifact and remote-reference variants, preserve mixed MCP content blocks, and prevent hosted Adapters from exposing managed filesystem paths. + +GraphQL operation results now share the Media pipeline with a 1 MiB inline threshold and a 100 MiB artifact cap. Pi renders local artifact paths and remote artifact references according to the result variant. diff --git a/apps/docs/src/content/docs/troubleshooting.mdx b/apps/docs/src/content/docs/troubleshooting.mdx index 0f50fcc3..aa89a605 100644 --- a/apps/docs/src/content/docs/troubleshooting.mdx +++ b/apps/docs/src/content/docs/troubleshooting.mdx @@ -196,12 +196,15 @@ full setup and repair workflow. ### Download returned an artifact -Expected symptom: a tool result contains `body.artifact` instead of inline bytes or text. - -Binary downloads, Google media downloads, and oversized HTTP-like responses are written as -Caplets media artifacts. Use the artifact `path` locally, or pass the artifact URI back to -another media-capable tool as `media.artifact`. If you need a specific destination for a -download, retry with `filename` or `outputPath` when the tool schema exposes those fields. +Expected symptom: a tool result has a top-level `kind` of `local-artifact` or +`remote-reference` instead of inline bytes or text. + +Binary downloads, Google media downloads, and oversized HTTP-like responses use Caplets media +results. Local runtimes return `local-artifact`; read its `path` locally or pass its artifact URI +back to another media-capable tool as `media.artifact`. Hosted runtimes return +`remote-reference`; use its `uri` because hosted results intentionally do not expose filesystem +paths. If you need a specific destination for a download, retry with `filename` or `outputPath` +when the tool schema exposes those fields. ### Remote attach fails diff --git a/docs/architecture.md b/docs/architecture.md index b52c5906..6f62dba2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -122,6 +122,10 @@ OpenAPI, Google Discovery, GraphQL, and HTTP backends expose explicit operation/ Google Discovery backends load local or remote Google Discovery documents, infer request base URLs from the document unless overridden, expose filtered Discovery methods as tools, and infer OAuth scopes from the exposed operation set. Google media downloads and oversized or binary HTTP-like responses are written as Caplets media artifacts under the configured artifact root instead of being forced inline. +HTTP-like backend results cross one internal Media contract. Small textual or JSON bodies use the `inline` variant. Non-inline results use `local-artifact` only when the host explicitly exposes its Caplets-managed artifact filesystem; remote and hosted boundaries use `remote-reference`, which carries an artifact URI and never filesystem path semantics. Backend managers produce this contract, while terminal, MCP, Attach, native, and browser Adapters own their local presentation. + +Each configurable HTTP-like backend retains its configured maximum response size as a hard failure cap. The shared HTTP reader's default remains 1 MiB. GraphQL operation results use the same 1 MiB inline threshold and a separate 100 MiB artifact cap; GraphQL schema and introspection remain bounded-text control paths. + ### CLI Tools CLI-backed Caplets expose curated actions only. Actions spawn declared commands and args without shell interpolation. Inputs are validated before spawn, and outputs are bounded. diff --git a/docs/plans/2026-07-09-001-refactor-caplets-architecture-deepening-plan.md b/docs/plans/2026-07-09-001-refactor-caplets-architecture-deepening-plan.md new file mode 100644 index 00000000..5642663c --- /dev/null +++ b/docs/plans/2026-07-09-001-refactor-caplets-architecture-deepening-plan.md @@ -0,0 +1,667 @@ +--- +title: Caplets Architecture Deepening - Plan +type: refactor +date: 2026-07-09 +topic: caplets-architecture-deepening +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +--- + +# Caplets Architecture Deepening - Plan + +## Goal Capsule + +- **Objective:** Deepen seven proven Caplets seams so non-inline results, backend dispatch, exposure registration, Vault quarantine facts, Current Host administration, PostHog privacy enforcement, and Native Project Binding lifecycle each have one authoritative Module and a small Interface. +- **Product authority:** `STRATEGY.md`, `CONTEXT.md`, `CONCEPTS.md`, `docs/adr/0001-code-mode-default-exposure.md`, `docs/adr/0002-media-artifacts-for-non-inline-results.md`, and `docs/adr/0003-remote-client-role-boundaries.md` define the behavior that this refactor must preserve or restore. +- **Execution profile:** Land exactly seven sequential, behavior-complete, independently reviewable clean-cutover commits in U-ID order. Characterize observable behavior at each Interface before deleting the shallow or duplicated path it replaces. +- **Stop conditions:** Stop and re-plan if a `remote-reference` would need filesystem resolution, if backend dispatch would need to own result presentation, if Caplets Exposure Projection would need callbacks or execution objects, if Current Host work would broaden Access Client authority or generic Raw Vault Reveal, if PostHog enforcement would remove required anonymous transport identity or the active provider projects cannot be verified to discard IP data, or if Project Binding would require one shared retry policy for Cloud and self-hosted transports. +- **Tail ownership:** Each unit owns its focused tests, deletion test, dead-code removal, and any behavior-required documentation or Changesets entry. Generated artifacts remain untouched unless a unit changes their authoritative source. +- **Open blockers:** None. + +--- + +## Product Contract + +### Summary + +Deepen seven existing Caplets Modules in a fixed risk- and Locality-informed sequence, starting with the canonical Media result contract and then moving the backend-operation dispatch that must preserve it. The refactor restores accepted ADR behavior, removes duplicated selection and prose parsing, gives adapters stable outcomes to render, and concentrates lifecycle and privacy policy at real seams without adding product features, compatibility shims, or parallel legacy paths. + +### Problem Frame + +Caplets already has useful deep Implementations for Media artifact storage, exposure discovery, Vault resolution, dashboard administration collaborators, browser event builders, and both Project Binding transports. The remaining friction is at their Interfaces: several production paths bypass shared policy, callers reconstruct facts from strings or parallel snapshots, adapters repeat selection and orchestration, and tests exercise transport ceremony instead of the behavior-owning Seam. + +That weakens Leverage because a correct implementation does not pay back across every caller. It weakens Locality because one behavioral change must be reproduced in several managers, adapters, or tests. The seven selected candidates all pass the deletion test once deepened: deleting the resulting Module would force material policy or lifecycle complexity back into multiple callers, while deleting the current forwarding or parsing path removes little useful behavior. + +### Selected Product Decisions + +- A canonical internal Media result with exactly three discriminants crosses the Media Seam: `inline`, `local-artifact`, and `remote-reference`. +- A `local-artifact` may carry a Caplets-managed filesystem path only when the host capability context enables local-path exposure. +- A `remote-reference` carries a reference or URI and has neither a filesystem path nor `pathResolution`; no implementation may collapse `path ?? uri` into path semantics. +- Host capability context owns local-path-versus-reference exposure. Backend managers do not infer host locality. +- Adapter-specific outer presentation remains local to each Adapter. Core result classification does not dictate terminal, MCP, Attach, native, or browser wording. +- U1 precedes U2. Backend-operation dispatch must return the U1 result unchanged. +- Code Mode remains the default exposure under ADR-0001, with progressive and direct exposure preserved as explicit alternatives. +- Remote Client Roles are exact and disjoint under ADR-0003 whenever remote credentials authenticate a role-gated request: Access Clients may use MCP, Remote Attach, and Project Binding but not Current Host administration; Operator Clients may use dashboard/admin surfaces but not Access-only runtime routes. Neither role is a superset of the other. The credential-owner self-revoke route remains authenticated but role-neutral for both roles. Configured `development_unauthenticated` runtime access remains a separate compatibility path and does not synthesize an Access Client. +- Per-site PostHog initialization remains local. Final-payload enforcement is provider-aware and preserves the public PostHog token plus anonymous `distinct_id`, while preventing known-user or person-profile attribution. PostHog’s project-level IP data capture setting must separately be verified as **Discard**; `$geoip_disable` prevents enrichment but does not prove source-IP storage is disabled. +- Native Project Binding has exactly two production Adapters: Cloud Presence and self-hosted Binding Sessions. Their common lifecycle invariants share one owner, while their failure policies remain distinct. + +### Actors + +- A1. **Agent caller:** consumes Code Mode, progressive, direct MCP, Attach, or native results and must receive faithful result and exposure semantics. +- A2. **Current Host Operator:** administers the Current Host through the cookie-and-CSRF dashboard Adapter or the Operator bearer Adapter. +- A3. **Public-site visitor:** generates categorical landing, docs, or catalog analytics without known-user or person-profile attribution. +- A4. **Native remote client:** composes local and upstream Caplets while Native Project Binding starts, updates, replaces, and closes. +- A5. **Maintainer:** changes and verifies behavior through the deep Module Interface rather than through shallow forwarding or transport ceremony. + +### Key Flows + +- F1. **Result production:** A backend Adapter produces bounded output, the Media Module classifies it as `inline`, `local-artifact`, or `remote-reference`, backend-operation dispatch returns it unchanged, and the outer host Adapter renders it. +- F2. **Backend execution:** Progressive, direct, completion-discovery, and nested Caplet callers submit one common operation to backend-operation dispatch, which selects one of seven manager Adapters. +- F3. **Exposure registration:** Exposure discovery supplies source facts to Caplets Exposure Projection; MCP and Attach Adapters render registration and callable identity without parallel snapshot lookups. +- F4. **Vault recovery:** Config loading quarantines only affected Caplets, emits a structured Vault quarantine outcome, preserves current warning prose, and lets Setup and Doctor render their own outputs without parsing text. +- F5. **Current Host administration:** A dashboard session, validated Operator bearer, or verified-loopback configured `development_unauthenticated` Adapter yields a trusted host-scoped Operator principal, Current Host administration executes a semantic query or command, and the Adapter serializes the structured outcome; non-loopback unauthenticated administration fails closed. +- F6. **Browser telemetry:** A site Adapter initializes PostHog locally, typed categorical events enter shared best-effort dispatch, PostHog augments the payload, and the final-payload sanitizer allows only the required provider envelope, anonymous transport identity, privacy controls, and approved categorical properties. +- F7. **Native Project Binding:** The native composite asks one lifecycle owner to start, update, replace, or close; the owner enforces common ordering while the selected Cloud or self-hosted Adapter applies its own protocol and failure policy. + +### Requirements + +**Cross-cutover discipline** + +- R1. The work must land as seven sequential, behavior-complete commits in the order U1 through U7, with U1 completed before U2 changes `packages/core/src/tools.ts` or `packages/core/src/engine.ts`. +- R2. Every unit must complete a clean cutover: migrate every caller, replace shallow tests with stronger Interface tests, remove obsolete forwarding or parsing code, and leave no shim, alias, fallback, or parallel authority. + +**Media result contract** + +- R3. All Media-capable result producers must use the canonical `inline`, `local-artifact`, and `remote-reference` result contract while preserving status, structured content, errors, integrity metadata, and bounded-response rules. +- R4. Host capability context must be the sole owner of local-path exposure; `remote-reference` must omit filesystem path and `pathResolution`, while outer Adapters remain free to render references or links locally. +- R5. GraphQL operation results and engine-backed HTTP Actions must traverse the production Media path. GraphQL keeps the existing 1 MiB bounded-text policy as its inline threshold and uses a separate named 100 MiB artifact hard-cap policy; HTTP Actions receive an inline threshold distinct from their configured hard cap. GraphQL HTTP-200 `errors` classification occurs before artifact presentation hides the body. +- R6. Progressive MCP conversion must preserve every downstream content block, its order and payload, `structuredContent`, `isError`, and non-Caplets `_meta`; it must not discard non-text blocks or synthesize a JSON fence as a substitute for them. + +**Backend-operation dispatch** + +- R7. One backend-operation dispatch Module must select among `DownstreamManager`, `OpenApiManager`, `GoogleDiscoveryManager`, `GraphQLManager`, `HttpActionManager`, `CliToolsManager`, and `CapletSetManager` for check, list, get, call, compact, and search operations. +- R8. Dispatch must preserve U1 results byte-for-byte and field-for-field at the handoff, while MCP-only resources, resource templates, prompts, and completion remain outside the common Interface. + +**Caplets Exposure Projection** + +- R9. Caplets Exposure Projection must be registration-ready for progressive Caplets, Code Mode Caplets, direct tools, direct resources, direct resource templates, direct prompts, and completions while retaining safe hidden-Caplet breadcrumbs. +- R10. MCP registration, Attach rendering, and Code Mode callable identity must consume the resolved Projection without raw-snapshot rehydration, enabled-config fallback, callbacks, engine references, sandbox policy, authentication, or transport policy inside the Projection Module. Reload must retain the last resolved Projection or fail closed until asynchronous discovery completes, never reconcile an optimistic static snapshot into callable state. + +**Caplets Vault quarantine outcomes** + +- R11. The config Module must return producer-owned structured Vault quarantine outcomes containing the already-known recovery facts so Setup and Doctor never infer behavior from warning punctuation or wording. +- R12. Vault deepening must preserve best-effort overlay loading, quarantine of only affected Caplets, all four current resolution reasons, stored-key remapping, current human warning prose, global-versus-remote recovery target semantics, redaction, and Operator-only remote Vault administration. + +**Current Host administration** + +- R13. One Current Host administration operations Module must own eligible read models, Caplet and catalog administration, remote-client mutations, safe Vault administration, structured outcomes, redaction, Operator Activity Log emission, and actor-session termination decisions. +- R14. The human dashboard and Operator bearer must remain two distinct Adapters: the dashboard owns cookies, CSRF, browser ceremony, HTTP presentation, and trusted development-principal synthesis only when `development_unauthenticated` is paired with a verified loopback listener; a non-loopback unauthenticated listener fails closed for dashboard and Current Host administration. The bearer Adapter owns exact Operator-role validation and HTTP presentation. Neither Adapter accepts request-supplied actor identity, and both pass a trusted host-scoped Operator principal to the same Module. +- R15. Raw Vault Reveal must remain a dashboard-only human ceremony with confirmation, no-store response handling, ephemeral UI display, and redacted activity; generic remote control must continue rejecting it. + +**PostHog final-payload enforcement** + +- R16. PostHog final-payload sanitization must run after SDK augmentation for landing, docs, and catalog, preserve the final envelope, public project token, anonymous `distinct_id`, and approved categorical properties, and enforce `$process_person_profile: false` plus `$geoip_disable: true` on every accepted event regardless of incoming values. It removes raw URLs and referrers, page title, person mutation fields, and unknown application properties. Before production or preview telemetry release, the active PostHog project’s IP data capture configuration must be verified and recorded as **Discard**; client payload settings alone do not satisfy source-IP privacy. +- R17. Each site must retain local SDK initialization and remain functional when initialization, final-hook processing, or capture fails synchronously; provider failure must leave analytics disabled or drop the event without interrupting module evaluation, page listeners, or primary site behavior. + +**Native Project Binding lifecycle** + +- R18. One Native Project Binding lifecycle owner must enforce start, active update, replacement, and close ordering; coalesce duplicate start work; prevent stop before successful start; cancel timers; and make close idempotent across both production Adapters. +- R19. Cloud must retain report-only heartbeat failure behavior, while self-hosted Binding Sessions retain exact unsupported suppression, disconnect-on-heartbeat-failure, and later re-registration; lifecycle failure must not broaden Project Binding Quarantine or remove unrelated Caplets. Under remote-credential authentication, MCP, Attach, and every Project Binding route require the exact Access role and reject Operator bearers, while dashboard/admin routes require the exact Operator role. An active Project Binding WebSocket must revalidate its durable Client ID’s current, non-revoked Access role and the authoritative active Map record before every control mutation; heartbeat and end/close transitions serialize so a stale second socket cannot reactivate an ended record. Configured `development_unauthenticated` runtime access remains unchanged. + - **Active-session invariant:** Re-read the durable Client ID, authoritative Map-record state, and unexpired `expiresAt` inside the per-record queue immediately before each client mutation executes; work accepted before a later revoke, demotion, expiry, end, or close must still fail when it reaches the queue. + +**Evidence and release** + +- R20. Every unit must have happy, edge, failure, and integration scenarios at the deepest valid Interface, with Adapter tests narrowed to host-specific rendering, authorization, transport, and lifecycle behavior. +- R21. Documentation and generated artifacts change only when shipped behavior or durable architecture requires them, and each user-facing package change carries a Changesets entry under repository conventions. + +### Acceptance Examples + +- AE1. **Local versus remote artifact:** Given identical oversized response bytes, when a local host permits path exposure, then the result is `local-artifact` with a managed path; when an HTTP, Attach, Cloud, or other path-hidden host handles it, then the result is `remote-reference` with the same URI and integrity facts but no path or `pathResolution`. +- AE2. **Oversized GraphQL error:** Given a GraphQL HTTP-200 JSON body over the inline threshold but below the hard cap and containing `errors`, when the operation runs, then the bytes become a Media artifact or reference and the call remains `isError: true`. +- AE3. **Mixed MCP content:** Given downstream text plus image or resource blocks and structured content, when the result passes through progressive execution, then every block remains in its original order and no replacement JSON-fence block appears. +- AE4. **Transparent dispatch:** Given any of the seven backend kinds returns a result containing `remote-reference`, `isError`, and downstream `_meta`, when common dispatch selects that Adapter, then the caller receives those fields unchanged and dispatch does not inspect host path policy. +- AE5. **Projection truth:** Given direct-only, disabled, setup-required, Project Binding-quarantined, and ready Code Mode Caplets, when MCP registration and Code Mode declarations render, then only ready projected identities are callable and no enabled-config fallback leaks hidden identities. +- AE6. **Vault copy independence:** Given the human Vault warning wording changes while its structured facts do not, when Setup and Doctor run, then their status, key, target, and recovery command remain correct without regex changes. +- AE7. **Administration parity:** Given the same eligible Current Host mutation through a dashboard session and an Operator bearer, when each succeeds, then both use the same policy, redaction, outcome, and actor-attributed activity; an Access Client remains forbidden and bearer Raw Vault Reveal remains rejected. +- AE8. **Final PostHog payload:** Given an augmented PostHog payload with `token`, anonymous `distinct_id`, categorical fields, raw URL, person mutation fields, and an unknown app field, when the final hook runs, then token and `distinct_id` survive while the prohibited fields are absent. +- AE9. **Analytics failure isolation:** Given `posthog.init`, the final hook, or `posthog.capture` throws in any site Adapter, when that module loads and page interactions continue, then no exception escapes and the primary interaction still completes. +- AE10. **Distinct binding failures:** Given a Cloud heartbeat fails, when the lifecycle remains active, then the error is reported without forced re-registration; given a self-hosted heartbeat fails, when a later lifecycle start occurs, then the old session is disconnected and a new Binding Session registers. + +### Success Criteria + +- All four ADR-0002 violations named by U1 are covered through production wiring rather than manager-only fixtures. +- No common operation caller contains a seven-way backend selection tree or positional optional-manager padding after U2. +- MCP registration no longer rebuilds source maps from `ExposureSnapshot`, and Code Mode execution no longer falls back to every enabled Caplet after U3. +- Setup and Doctor contain no Vault-warning regex or warning-substring behavior branch after U4. +- Current Host policy, activity, redaction, and session-ended decisions are tested directly through the operations Interface after U5. +- All three sites install the same final-payload policy and prove init, hook, and capture failure isolation after U6. +- Native composition has one lifecycle owner, two production Adapters, no `LocalPresenceManager` alias, and preserved adapter-specific failure loops after U7. + +### Scope Boundaries + +**In scope** + +- The seven named clean cutovers and only the behavioral defects needed to make each Interface truthful. +- Focused Interface, Adapter, and cross-layer integration tests listed in each unit. +- `docs/architecture.md` updates for the completed Media and Current Host ownership changes. +- Public telemetry privacy wording corrections that distinguish anonymous provider routing identity from known-user/person attribution and user credentials. +- Changesets for U1, U2, U3, U5, and U7 observable package behavior. +- The U1 Changeset-backed replacement of existing exported `CapletResultMetadata.artifacts` / `CapletArtifact` presentation metadata required to represent remote references without filesystem semantics. + +**Out of scope** + +- New backend kinds, configuration syntax, generated config or Code Mode API schema fields, unrelated public protocol fields, Media download endpoints, remote-reference resolvers, retry systems, telemetry providers, or analytics-provider abstraction. +- Moving backend request construction, host rendering, Code Mode execution, sandbox policy, authentication, or transport behavior into the Media or Projection Modules. +- Multi-host dashboard administration, dashboard redesign, a new dashboard approval lane, Access Client privilege expansion, or generic Raw Vault Reveal. +- Changing current Caplets Vault warning prose, adding remote Setup behavior, changing Vault storage or grants, or copying values between runtime-owned Vaults. +- Upgrading PostHog, moving SDK initialization into the shared Module, adding known-user analytics, or carrying browser identity into CLI/runtime telemetry. +- Folding foreground `runProjectBindingSession`, Mutagen orchestration, Remote Attach polling, exposure quarantine policy, Remote Profile selection, or a unified retry algorithm into the Native lifecycle owner. +- Maintaining historical implementation plans as live compatibility documents; prior plans remain decision and pattern anchors. + +### Dependencies and Assumptions + +- ADR-0001, ADR-0002, and ADR-0003 remain accepted and require no amendment. +- `posthog-js@1.395.0` remains the pinned browser provider contract for U6. +- Current behavior and accepted decisions captured by the cited prior plans are implementation anchors subordinate to this Product Contract and accepted ADRs; this plan deepens their ownership without reopening product scope. +- U1 and U2 are a hard dependency because both change `packages/core/src/tools.ts` and `packages/core/src/engine.ts`. U3 through U7 follow the fixed delivery order so each review starts from a behavior-complete predecessor. +- External PostHog documentation and the pinned SDK source are load-bearing only for U6’s provider-side IP-discard gate; repository code, tests, domain documents, ADRs, and prior plans resolve every other decision. + +### Sources and Research + +- `STRATEGY.md` +- `CONTEXT.md` +- `CONCEPTS.md` +- `docs/agents/domain.md` +- `docs/adr/0001-code-mode-default-exposure.md` +- `docs/adr/0002-media-artifacts-for-non-inline-results.md` +- `docs/adr/0003-remote-client-role-boundaries.md` +- `docs/plans/2026-07-01-001-refactor-caplets-exposure-projection-plan.md` +- `docs/plans/2026-07-03-001-feat-caplets-admin-dashboard-plan.md` +- `docs/plans/2026-06-22-001-feat-caplets-vault-plan.md` +- `docs/plans/2026-06-28-002-feat-telemetry-observability-loop-plan.md` +- `docs/plans/2026-06-25-001-feat-self-hosted-project-binding-plan.md` +- The source and test paths named in the Implementation Units below. +- [PostHog IP data capture controls](https://posthog.com/docs/privacy/data-collection#ip-data-capture) +- [`posthog-js@1.395.0` source warning that client `ip` configuration has no effect](https://unpkg.com/posthog-js@1.395.0/lib/src/posthog-core.js) +- [PostHog IP-redaction guidance](https://posthog.com/tutorials/web-redact-properties#hiding-customer-ip-address) + +--- + +## Planning Contract + +### Product Contract Preservation + +The Product Contract above is the direct planning bootstrap from the user-approved seven-candidate scope and accepted repository decisions. Planning added no product feature. It resolves only ownership, migration, sequencing, test-surface, release, and documentation decisions needed to execute that fixed scope. + +### Key Technical Decisions + +- KTD1. **Put the canonical result inside the Media Module.** Add a discriminated internal result representation under `packages/core/src/media/` with exactly `inline`, `local-artifact`, and `remote-reference`. The Interface carries observed status/body/content/integrity facts without prescribing outer host copy. +- KTD2. **Separate bounded acquisition, semantic classification, and presentation.** Shared Media Implementation owns bounded bytes, MIME/attachment policy, thresholding, storage, checksum, filename, and path visibility. Keep the existing `DEFAULT_MAX_RESPONSE_BYTES` policy at 1 MiB; introduce a distinct named 100 MiB artifact hard-cap policy aligned with OpenAPI and Google Media, and pass it explicitly for GraphQL operation output. GraphQL retains backend-local `errors` classification over already-bounded bytes before representation selection hides the inline body. Host Adapters own display wording and link affordances. +- KTD3. **Use one named runtime with two deliberate capabilities.** Construct the backend-operation dispatch once with all seven named manager Adapters and normalize only the six common operations. Exported `handleServerTool` changes from positional managers to `{ operations, mcp }`: `operations` owns common backend dispatch, while `mcp` preserves the separately named downstream resource/prompt/completion capability. This is an intentional released `@caplets/core` contract break documented by a Changeset. +- KTD4. **Make Projection entries registration-ready, not executable.** Each of the seven entry kinds carries the resolved source facts its renderers need. MCP callbacks, Attach revisions and route maps, Code Mode execution, authentication, and transport remain Adapter-local. +- KTD5. **Return Vault facts once.** The config Module emits a discriminated Vault quarantine outcome in the same loop that removes the affected Caplet. Generic warning presentation remains available; Setup and Doctor consume data, not copy. +- KTD6. **Use one Current Host semantic operations Interface.** The Module receives a trusted host-scoped Operator principal plus a Current Host query or command and returns a structured outcome, including actor activity and session-ended state. Dashboard and bearer Adapters retain authentication and serialization. `development_unauthenticated` may synthesize a development principal only on a verified loopback listener; non-loopback unauthenticated administration fails closed. +- KTD7. **Enforce PostHog privacy at both relevant boundaries.** A provider-specific Module accepts the augmented capture envelope and returns a sanitized envelope or drop. It preserves only the required structural envelope, public token, anonymous `distinct_id`, and approved categorical properties, and enforces `$process_person_profile: false` plus `$geoip_disable: true`; it is not a generic analytics-provider Interface. Because browser requests still reveal a source IP, release readiness separately requires PostHog’s project-level IP data capture configuration to be verified as **Discard**. +- KTD8. **Make initialization and dispatch best effort at the shared policy level.** Each site still calls `posthog.init` locally with flags, referrer persistence, and campaign-parameter capture disabled so pre-hook `/flags` traffic cannot carry browser navigation facts. Local failure containment leaves analytics disabled and allows module evaluation to continue. Shared capture dispatch and final sanitization are total and no-throw. +- KTD9. **Own only common Native lifecycle invariants.** The lifecycle owner coordinates when to start, update, replace, and close. Cloud and self-hosted Adapters own protocol state, timer callbacks, error classification, and intentionally different recovery policy. +- KTD10. **Replace, do not layer.** Every unit uses the deletion test: new Interface tests land first, all callers migrate in the same unit, and obsolete forwarding, fallback, regex, alias, or orchestration code is removed before the unit is reviewable. + +### High-Level Technical Design + +#### Architecture and data flow + +```mermaid +flowchart TB + Callers[Progressive, direct, listCompletionTools completion-discovery, and nested callers] --> Dispatch[Backend-operation dispatch Module] + Dispatch --> Managers[Seven backend manager Adapters] + Managers --> Media[Media result Module] + Media --> Hosts[MCP, Attach, native, and Pi Adapters] + + Discovery[Exposure discovery facts] --> Projection[Caplets Exposure Projection Module] + Projection --> ExposureHosts[MCP registration and Attach rendering Adapters] + + Config[Config and Vault resolver facts] --> VaultOutcome[Structured Vault quarantine outcome] + VaultOutcome --> VaultConsumers[Setup, Doctor, and warning presentation] + + Dashboard[Human dashboard Adapter] --> CurrentHost[Current Host administration operations Module] + Bearer[Operator bearer Adapter] --> CurrentHost + CurrentHost --> HostState[Engine, credential store, catalog, Vault, activity log] + + Sites[Landing, docs, and catalog Adapters] --> PostHog[PostHog SDK augmentation] + PostHog --> FinalSanitizer[Final-payload sanitizer Module] + FinalSanitizer --> Transport[PostHog transport] + + Composite[Native composite runtime] --> Lifecycle[Native Project Binding lifecycle owner] + Lifecycle --> Cloud[Cloud Presence Adapter] + Lifecycle --> SelfHosted[Self-hosted Binding Session Adapter] +``` + +Each thickening targets one real Seam. The Adapters keep variation that belongs to their host or transport; the Module hides policy or lifecycle complexity that callers should not relearn. + +#### Canonical Media result flow + +```mermaid +flowchart TB + Source[Bounded response bytes or downstream content blocks] --> Classify[Media classification and semantic inspection] + Classify -->|small JSON or text| Inline[inline] + Classify -->|binary, attachment, or oversized| Store[Caplets-managed artifact write] + Store --> HostCapability{Host permits local path exposure?} + HostCapability -->|yes| LocalArtifact[local-artifact with managed path] + HostCapability -->|no| RemoteReference[remote-reference with URI or reference] + UriOnly[Upstream URI-only artifact] --> RemoteReference + Inline --> Presentation[Adapter-local outer presentation] + LocalArtifact --> Presentation + RemoteReference --> Presentation +``` + +`remote-reference` never acquires a filesystem path or `pathResolution`. Markdown links that genuinely name downstream filesystem paths remain separate presentation hints and do not redefine the canonical Media contract. + +#### Fixed delivery order + +```mermaid +flowchart TB + U1[U1 Media result contract] --> U2[U2 Backend-operation dispatch] + U2 --> U3[U3 Caplets Exposure Projection] + U3 --> U4[U4 Vault quarantine outcomes] + U4 --> U5[U5 Current Host administration] + U5 --> U6[U6 PostHog final payload] + U6 --> U7[U7 Project Binding lifecycle] +``` + +U1 to U2 is a semantic dependency. The remaining arrows are delivery dependencies that preserve the requested commit order and isolate risk; U7 also relies semantically on U3’s stable exposure and quarantine authority. + +#### Native Project Binding lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Inactive + Inactive --> Starting: owner requests start + Starting --> Active: Adapter registration succeeds + Starting --> Inactive: Adapter start fails under its policy + Active --> Updating: allowed Caplet set changes + Updating --> Active: Adapter applies or records update + Active --> Active: Cloud heartbeat failure reports only + Active --> Inactive: self-hosted heartbeat failure disconnects + Inactive --> Starting: later self-hosted lifecycle start + Starting --> Closing: close waits for settled start + Active --> Closing: close or replacement + Updating --> Closing: close or replacement + Closing --> Closed: timers cleared and eligible remote stop completes + Closed --> Closed: repeated close is a no-op +``` + +The owner enforces ordering and race invariants. It does not make the two failure loops identical. + +#### PostHog privacy gauntlet + +```mermaid +flowchart TB + LocalInit[Site-local SDK initialization] -->|success| Capture[Shared best-effort categorical capture] + LocalInit -->|throws| Disabled[Analytics disabled; site continues] + Capture --> Augmented[PostHog augmented final payload] + Augmented --> Sanitize[Provider-aware final sanitizer] + Sanitize -->|valid| Allowed[token and anonymous distinct_id retained] + Sanitize -->|malformed or unsafe| Drop[Drop without throwing] + Allowed --> Network[Provider transport] +``` + +### Sequence and Dependencies + +1. **U1 restores ADR-0002 first.** It changes result types and the two shared hot files, then proves production engine and host behavior. +2. **U2 moves only selection.** It consumes U1’s final result contract and must not normalize it again. +3. **U3 deepens registration truth.** It follows the engine and dispatch cutover so the Projection Interface is built on stable manager selection and result behavior. +4. **U4 removes a contained in-process parser detour.** This lower-risk producer-to-consumer cutover provides a clean review checkpoint before authorization-sensitive work. +5. **U5 centralizes Current Host policy.** It follows U4 so Vault diagnostic and recovery facts are already stable before broader safe Vault administration moves behind the operations Interface. +6. **U6 closes the public-site privacy gap.** It is isolated after core administration changes because it lives in separate private workspace packages and has a distinct provider contract. +7. **U7 moves concurrent lifecycle ownership last.** It builds on U3’s projection authority and carries the highest race and transport-policy risk. + +### System-Wide Impact + +- **Result fidelity:** MCP content blocks, HTTP-like envelopes, GraphQL error state, Media integrity metadata, Attach/native forwarding, and Pi presentation all share one result truth without sharing one presentation. +- **Agent/tool parity:** Code Mode, progressive, direct MCP, Attach, and native surfaces consume the same resolved exposure identities; hidden or quarantined Caplets do not reappear through fallback. +- **Authorization:** Remote Client Roles are exact, not hierarchical, on role-gated remote-credential routes. Current Host administration accepts only validated Operator Client principals or a verified-loopback development principal; bearer-authenticated MCP, Attach, and Project Binding accept only Access Clients, including active Project Binding message revalidation; credential self-revoke accepts either authenticated role but only for the caller’s own credential. Request data never supplies actor identity, configured `development_unauthenticated` runtime access remains unchanged, and non-loopback unauthenticated listeners expose no dashboard or Current Host administration authority. +- **Privacy:** PostHog keeps the minimum anonymous transport identity needed for ingestion while final-payload policy prevents raw navigation and person-profile mutation fields from leaving any public site; the provider project’s verified **Discard** setting prevents source-IP retention that client payload hooks cannot control. +- **Lifecycle:** Native Project Binding becomes easier to reason about without moving quarantine, Mutagen, Remote Attach, or transport-specific recovery into the lifecycle Interface. +- **Maintenance:** Each candidate’s Interface becomes its primary test surface, increasing Leverage and Locality while shrinking adapter fixtures and setup ceremony. + +### Edge Cases and Failure Conditions + +- **Media:** Exact threshold boundaries, `HEAD`, missing MIME, attachment disposition, non-2xx responses, HTTP-200 GraphQL errors, aborted streams, advertised and streamed hard-cap overflow, unsafe artifact roots, path-hidden hosts, URI-only references, image-only MCP results, and mixed ordered blocks. +- **Dispatch:** Inspect requests that must not start an Adapter, check-method name normalization, nested Caplet sets, invalid args rejected before Adapter invocation, selected Adapter errors passed through, and MCP-only operations rejected outside their separate implementation. +- **Projection:** All seven entry kinds, skipped non-direct MCP discovery, hidden reasons, sanitized diagnostics, namespace-qualified identity, reload removal/update, static-to-resolved transition, stale Attach revisions, and absence of Code Mode fallback. +- **Vault:** Missing, ungranted, unavailable, and invalid-key-source outcomes; remapped stored keys; multiple issues per Caplet; project and Markdown source paths; remote recovery target; loader failure; unaffected sibling retention; and secret redaction. +- **Current Host:** Missing or Access principal, expired Pending Remote Login, unknown client, self-revocation, self-demotion, another Operator’s mutation, session-store lock contention, catalog conflicts, unavailable control context, safe Vault redaction, double activity emission, and Raw Vault Reveal isolation. +- **PostHog:** Null or malformed final payload, missing required token or anonymous identity, unknown SDK/app properties, raw URL/referrer/title, person mutation fields, init failure before listeners, hook failure, capture failure, absent env configuration, and provider-version drift. +- **Project Binding:** Concurrent starts, close during start, close during remote replacement, repeated close, failed reload, unchanged allowed IDs, update before active registration, timer cancellation, failed remote stop, exact versus nonmatching unsupported errors, Cloud report-only heartbeat failure, self-hosted disconnect/re-registration, owner isolation, and Access Client authorization. + +### Risks and Mitigations + +| Risk | Unit | Mitigation | +| ------------------------------------------------------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GraphQL artifactization hides `errors` and turns an error into success. | U1 | Classify the already-bounded GraphQL body before representation selection and require an oversized HTTP-200 error scenario. | +| URI-only results regain fake path semantics through metadata or Pi rendering. | U1, U2 | Make `remote-reference` a distinct discriminant, forbid path and `pathResolution`, and assert preservation through dispatch and Pi. | +| Dispatch erases backend types or becomes a second behavior owner. | U2 | Limit the Interface to six common operations, construct it with seven named Adapters, and keep validation, Media policy, and MCP-only behavior outside. | +| Projection becomes a callback registry or retains a second snapshot authority. | U3 | Delete raw-snapshot parameters and fallback maps; require declarative registration facts and Adapter-local callbacks. | +| Structured Vault outcomes drift from current human output or leak secrets. | U4 | Produce facts and warning text in the same config loop, retain prose assertions, and test redaction and target mapping separately. | +| Current Host migration weakens authorization or duplicates activity. | U5 | Pass validated principals, add direct Interface authorization tests, compare both Adapters, and delete route-local activity only after parity passes. | +| PostHog sanitization either drops ingestion or retains person data. | U6 | Pin the focused provider-contract fixture to token plus anonymous `distinct_id`, deny person mutation/raw fields, and exercise all three installed hooks. | +| Lifecycle ownership accidentally unifies Cloud and self-hosted failure policy. | U7 | Keep policy in the two Adapters and test the two failure loops independently through one owner. | +| Concurrent refactor leaves dead paths that still appear authoritative. | All | Treat deletion as part of each unit’s done signal and forbid compatibility aliases, fallback parsing, and parallel selection. | + +### Alternative Approaches Considered + +- **Patch each Media bypass independently:** rejected because it recreates MIME, threshold, storage, block, and reference policy across producers and fails the deletion test. +- **Keep `backendFor` and wrap the engine trees:** rejected because layering preserves the shallow forwarding objects and leaves multiple selection authorities. +- **Put callbacks and engine objects into Projection entries:** rejected because it expands the Interface, reduces testability, and moves execution into the wrong Module. +- **Add typed Vault facts beside regex fallbacks:** rejected because two authorities would remain and warning-copy changes could still alter behavior. +- **Make dashboard routes the Current Host Module:** rejected because the Operator bearer Adapter would still duplicate policy and activity. +- **Install the current property-only PostHog filter directly:** rejected because it runs at the wrong shape and removes required provider fields. +- **Unify Cloud and self-hosted Project Binding into one retry state machine:** rejected because only lifecycle ordering is shared; failure and recovery policy intentionally varies by Adapter. +- **Land all seven in one commit:** rejected because it would erase reviewable deletion tests and make result, authorization, privacy, and concurrency regressions difficult to isolate. + +### Documentation, Generated Artifacts, and Release Decisions + +| Unit | Documentation / generated artifacts | Changeset decision | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| U1 | Update `docs/architecture.md` for GraphQL operation results, the three canonical variants, and host-owned presentation. No schema or Code Mode generation. | Required for observable `@caplets/core` result metadata and `@caplets/pi` rendering changes. | +| U2 | No documentation or generated artifact change; the Changeset carries the exported `handleServerTool` migration contract. | Required for the intentional released `@caplets/core` Interface break. | +| U3 | Existing Projection glossary and architecture text already state the intended authority; no generated artifact change. | Required for corrected `@caplets/core` registration and Code Mode callable visibility. | +| U4 | No documentation or generated artifact change because warning and CLI/Doctor output remain stable. | None while public output stays unchanged. | +| U5 | Update `docs/architecture.md` for the Current Host operations Module and its two Adapters. | Required for observable Operator bearer activity and Current Host administration behavior. | +| U6 | Correct `docs/product/anonymous-telemetry.md` and `apps/docs/src/content/docs/privacy/indexing.mdx`: no known-user/person-profile attribution or user credentials are retained, while PostHog’s configured public project token and anonymous `distinct_id` remain solely for provider routing. Correct `docs/product/telemetry-provider-readiness.md` so the PostHog project-level **Discard IP data** setting is a recorded release gate and `$geoip_disable` is not misrepresented as a source-IP storage control. No lockfile or generated artifact change. | None; all affected apps and `@caplets/web-observability` are private workspace packages. | +| U7 | No documentation or generated artifact change; the lifecycle contract gains observable watch-driven allowed-Caplet updates and stronger cleanup ordering. | Required for corrected `@caplets/core` Project Binding lifecycle behavior. | + +### Critical Files + +| Path | Why it is critical | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `packages/core/src/http/response.ts` | Existing deep Media Implementation for bounded response classification and artifact writing. | +| `packages/core/src/media/artifacts.ts` | Managed storage, URI, checksum, filename, path visibility, and safe resolution behavior. | +| `packages/core/src/tools.ts` | Progressive wrapper, result annotation, artifact metadata defect, and current shallow backend selection. | +| `packages/core/src/engine.ts` | Manager construction, production Media options, progressive/direct entrypoints, and repeated dispatch trees. | +| `packages/core/src/exposure/projection.ts` | Named adapter-neutral source of exposure identity and availability. | +| `packages/core/src/serve/session.ts` | MCP registration rehydration and Code Mode enabled-config fallback to delete. | +| `packages/core/src/config.ts` | Producer already holding every Vault quarantine fact before warning formatting. | +| `packages/core/src/serve/http.ts` | Human dashboard and Operator bearer Adapters plus current direct administration orchestration. | +| `packages/core/src/remote-control/dispatch.ts` | Generic bearer dispatch that must delegate eligible Current Host operations but retain Raw Vault Reveal rejection. | +| `packages/web-observability/src/privacy.ts` | Current unused property-only PostHog filter and established categorical allowlist. | +| `apps/landing/src/scripts/observability.ts` | Representative local PostHog initialization and direct capture Adapter. | +| `packages/core/src/native/service.ts` | Composite lifecycle ordering and self-hosted Binding Session Adapter. | +| `packages/core/src/cloud/presence.ts` | Cloud Presence Adapter and misleading `LocalPresenceManager` alias. | +| `docs/adr/0001-code-mode-default-exposure.md` | Code Mode default and alternate exposure contract. | +| `docs/adr/0002-media-artifacts-for-non-inline-results.md` | Local path versus remote reference decision. | +| `docs/adr/0003-remote-client-role-boundaries.md` | Access versus Operator authority and Raw Vault Reveal decision. | + +--- + +## Implementation Units + +### U1. Make Media artifacts the canonical non-inline result contract + +- **Goal:** Deepen the established Media Module so every result producer crosses one `inline` / `local-artifact` / `remote-reference` Interface and every host Adapter renders that result without inventing locality. +- **Requirements:** R1-R6, R20-R21; F1; AE1-AE3. +- **Dependencies:** None. +- **Files:** + - **Create:** `packages/core/src/media/results.ts` + - **Modify:** `packages/core/src/media/artifacts.ts`, `packages/core/src/media/index.ts`, `packages/core/src/http/response.ts`, `packages/core/src/graphql.ts`, `packages/core/src/http-actions.ts`, `packages/core/src/result-content.ts`, `packages/core/src/tools.ts`, `packages/core/src/engine.ts`, `packages/core/src/caplet-sets.ts`, `packages/core/src/serve/http.ts`, `packages/core/src/serve/index.ts`, `packages/core/src/index.ts`, `packages/pi/src/index.ts` + - **Tests:** `packages/core/test/media-artifacts.test.ts`, `packages/core/test/graphql.test.ts`, `packages/core/test/http-actions.test.ts`, `packages/core/test/result-content.test.ts`, `packages/core/test/downstream.test.ts`, `packages/core/test/tools.test.ts`, `packages/core/test/runtime.test.ts`, `packages/core/test/native.test.ts`, `packages/core/test/caplet-sets.test.ts`, `packages/core/test/serve-http.test.ts`, `packages/core/test/serve-session.test.ts`, `packages/core/test/attach-api.test.ts`, `packages/pi/test/pi.test.ts` + - **Documentation / release:** `docs/architecture.md`, `.changeset/*.md` +- **Approach:** + - Define the canonical internal Media result in `packages/core/src/media/results.ts` using the three selected discriminants. Reuse observed fields such as URI, managed path, filename, MIME type, byte length, and checksum; do not invent filesystem fields for a reference. + - Keep the canonical Media result type internal under `packages/core/src/media/`. Replace the existing exported `CapletArtifact` presentation metadata with a `presentation`-discriminated union: `local-path` retains `displayPath` and `pathResolution`, while `reference` carries `reference` and forbids both filesystem fields. `CapletResultMetadata.artifacts` uses that union, Markdown remains an Adapter-local presentation hint, and `packages/core/src/index.ts` does not root-export the internal result, `MediaArtifact`, or HTTP reader options. + - Keep storage safety, URI construction, checksum, naming, permission, root, and symlink behavior in `packages/core/src/media/artifacts.ts`. A hidden path does not become a remotely resolvable path; it yields `remote-reference` to callers. + - Pass path exposure as an explicit host capability at runtime/engine construction, never infer it from a transport label. In-process hosts that can access the managed artifact filesystem may enable it, including `CapletsRuntime`, local CLI/Code Mode construction, local native, and native Pi/OpenCode; every remote or hosted boundary forces it off, including HTTP serve/session factories, Attach, and Cloud. + - Propagate the inline threshold, hard-cap, artifact directory, and path-exposure capability recursively through every `CapletSetManager` child runtime so nested HTTP Actions use the same Media contract. + - In `serveHttp`, `serveHttpWithSessionFactory`, and `serveHttpWithUpstream`, construct one sanitized `{ ...callerEngineOptions, exposeLocalArtifactPaths: false }` before any MCP, Attach, session, or upstream-native factory closure captures options. Pass that same object through the outer HTTP engine and `createUpstreamNativeService` composite local engine so no caller `true` can leak local-overlay paths through a remotely served MCP or Attach surface. + - Deepen `readHttpLikeResponse` so bounded acquisition and representation selection serve OpenAPI, Google Discovery, HTTP Actions, and GraphQL operation output. GraphQL schema and introspection readers remain bounded-text control paths. + - Let GraphQL inspect the already-bounded operation body for `errors` before representation selection. Preserve redirects, 401/403 redaction, timeout mapping, status headers, and `isError` on both inline and artifactized bodies. + - Add a named engine-level Media inline threshold to `CapletsEngineOptions` and forward it through `selectHttpLikeOptions`. Keep each configurable HTTP-like backend’s maximum response bytes as its hard failure cap. Do not repurpose `DEFAULT_MAX_RESPONSE_BYTES`: it remains the 1 MiB inline/control-reader policy. Introduce a separate named 100 MiB artifact hard-cap policy and pass it explicitly for GraphQL operation results without adding new config syntax. + - Change progressive MCP annotation to preserve the original ordered `content` blocks. Keep explicit field projection behavior, but delete canonical conversion through `textBlocksToString` where it discards non-text blocks or creates a JSON-fence substitute. + - Replace `addStructuredArtifact`’s `path ?? uri` model. Structured `local-artifact` metadata may feed an Adapter’s absolute-path presentation; structured `remote-reference` metadata carries its reference and never `pathResolution`. Existing Markdown filesystem-link detection remains a separate presentation hint. + - Update Pi’s Adapter-local parser and rendering so local artifacts render as managed paths and remote references render as references or links. Pi must not require `displayPath` plus `pathResolution` for every artifact. +- **Execution note:** Start with failing production-path tests for oversized GraphQL, engine-configured HTTP Action thresholding, mixed MCP blocks, URI-only metadata, and Pi reference rendering. Preserve the existing Media safety suite before broadening callers. +- **Patterns to follow:** `readHttpLikeResponse`, `writeMediaArtifact`, `resolveMediaArtifact`, the direct-result preservation in `CapletsEngine.executeDirectTool`, and host path suppression in HTTP serve, Attach, and Cloud construction. +- **Test scenarios:** + - **Happy:** Small GraphQL JSON and HTTP Action text remain `inline`; binary, attachment, and oversized output become `local-artifact` when an in-process host can access the managed artifact filesystem; the same bytes become `remote-reference` with identical URI, filename, MIME, length, and checksum when HTTP serve, Attach, Cloud, or another boundary hides local paths. + - **Happy:** Progressive and direct MCP calls preserve original text, image/resource-like blocks, structured content, `isError`, and downstream `_meta`, with only Caplets metadata added once. + - **Edge:** Exercise exactly-at-1-MiB and one-byte-over GraphQL inline behavior, the 100 MiB GraphQL hard cap by advertised length and streamed bytes, configurable HTTP Action threshold boundaries, attachment without MIME, `HEAD`, empty body, path-hidden artifact storage, URI-only upstream artifact, image-only MCP content, mixed content ordering, and non-2xx inline or artifact envelopes. + - **Failure:** Preserve content-length and streamed hard-cap cancellation without allocating the full 100 MiB GraphQL fixture, abort/timeout mapping, unsafe root/output/symlink rejection, failed-response output-path protection, GraphQL auth redaction, and no attempt to resolve a `remote-reference` against the local artifact root for presentation. + - **Integration:** Run real `CapletsEngine` and nested Caplet-set HTTP Actions through progressive and direct execution with an inline threshold lower than the hard cap; require the nested path-hidden result to remain a `remote-reference`. Route a GraphQL oversized HTTP-200 `errors` response through the same Media Implementation; prove `CapletsRuntime` and local native construction may expose managed paths while HTTP serve/session factories force reference-only results even when caller engine options request path exposure. Start `serveHttpWithUpstream` with caller `exposeLocalArtifactPaths: true` and exercise both its upstream composite MCP and Attach paths; each returns reference presentation with no managed path or `pathResolution`. Verify Pi renders a reference without `relative-to-mcp-server` or any local-path affordance. +- **Deletion test:** Remove the GraphQL operation `readGraphQlText` bypass, the progressive content replacement path, the structured `path ?? uri` fallback, Pi’s all-artifacts-are-paths parser, and every remote serve factory closure that can capture unsanitized engine options. Deleting the deepened Media Module afterward would force streaming limits, MIME policy, storage, checksums, naming, path exposure, and result discrimination back into multiple producers and Adapters. +- **Verification:** U1 is complete when focused Media, GraphQL, HTTP Action, result-content, tools, nested runtime, HTTP serve/upstream composite MCP and Attach, and Pi suites prove the three variants, block fidelity, and non-overridable host path boundary through production wiring; `@caplets/core` and `@caplets/pi` type/package gates accept the new result contract; architecture text and Changesets entries match the observable behavior. + +### U2. Centralize backend-operation dispatch at the manager Seam + +- **Goal:** Replace seven shallow forwarding objects and three engine selection trees with one deep backend-operation dispatch Module that preserves U1 results unchanged. +- **Requirements:** R1-R2, R7-R8, R20-R21; F2; AE4. +- **Dependencies:** U1. +- **Files:** + - **Create:** `packages/core/src/backend-operation-dispatch.ts`, `packages/core/test/backend-operation-dispatch.test.ts` + - **Modify:** `packages/core/src/tools.ts`, `packages/core/src/engine.ts`, `packages/core/src/caplet-sets.ts`, `packages/core/src/index.ts` + - **Tests:** `packages/core/test/tools.test.ts`, `packages/core/test/http-actions.test.ts`, `packages/core/test/google-discovery.test.ts`, `packages/core/test/openapi.test.ts`, `packages/core/test/cli-tools.test.ts`, `packages/core/test/downstream.test.ts`, `packages/core/test/caplet-sets.test.ts`, `packages/core/test/serve-session.test.ts`, `packages/core/test/native.test.ts` + - **Release:** `.changeset/*.md` +- **Approach:** + - Add one backend-operation dispatch Module constructed once with the seven named manager Adapters. Its small Interface normalizes check, listTools, getTool, callTool, compact, and search. + - Move backend discriminant selection and check-method name normalization into that Module. Keep each manager’s caching, auth, request construction, errors, Media production, `compact`, and `search` Implementation local. + - Deliberately change exported `handleServerTool` to receive one named runtime object such as `{ operations: BackendOperationDispatch, mcp: McpOperationAdapter }` rather than downstream plus six optional positional managers. Export the minimal runtime, dispatch constructor, and Interface from `packages/core/src/index.ts`, and document the released `@caplets/core` break in a Changeset. + - Replace `CapletsEngine.listCompletionTools`, `listTools`, and `callTool` selection trees with the runtime’s `operations` dispatch. Keep engine reload, invalidation, Project Binding guards, and MCP-only direct operations in the engine. + - Give each `CapletSetManager` child runtime its own complete named runtime object over its child managers so nested Caplets use the same Interface without rebuilding forwarding objects per call. + - Keep MCP resources, resource templates, prompts, reads, prompt retrieval, completion, and the MCP-specific `compactResource`, `compactResourceTemplate`, `compactPrompt`, `searchResources`, and `searchPrompts` methods behind the runtime’s separately named `mcp` capability, implemented directly by the child or root `DownstreamManager`. Do not move generic tool `compact` or `search` out of the six-operation backend dispatch, add MCP-only methods to that dispatch, or generalize `mcpBackendFor` into the common operation set. + - Keep the exported dispatch surface narrow: advanced external callers construct one named manager bundle per runtime, while ordinary callers continue through `CapletsEngine`; no caller constructs seven forwarding objects per operation. +- **Execution note:** Add Interface selection tests before migrating callers, then delete optional manager fixtures and positional padding in the same unit. +- **Patterns to follow:** Existing seven managers’ common method sets, the engine’s complete manager construction, and the child runtime construction in `packages/core/src/caplet-sets.ts`. +- **Test scenarios:** + - **Happy:** For each backend discriminator, every one of the six common operations reaches exactly the matching Adapter and no other Adapter; progressive and direct engine paths select the same Adapter. + - **Happy:** Invoke the exported `handleServerTool` public contract with one named runtime and a real MCP manager; resources, resource templates, reads, prompts, prompt retrieval, and completion still work through `runtime.mcp`, while a non-MCP server never enters that capability. + - **Edge:** Normalize the five current check method names; prove `inspect` stays registry-local and starts no Adapter; prove nested Caplet sets list and call a child through child dispatch; keep GraphQL field-selection restrictions in wrapper policy. + - **Failure:** Invalid call args fail before Adapter invocation; a selected Adapter’s `TOOL_NOT_FOUND`, timeout, auth, or structured error passes through unchanged; non-MCP resource/prompt/template/completion remains unsupported without entering common dispatch. + - **Integration:** Retain real OpenAPI, Google Discovery, HTTP Action, CLI, MCP resource/prompt/completion, and nested Caplet wrapper scenarios after replacing positional construction. Pass a U1 result containing mixed blocks, `local-artifact`, `remote-reference`, `isError`, and downstream `_meta` through progressive and direct dispatch and assert the handoff is unchanged; package type/build gates prove the root-exported runtime contract is consumable. +- **Deletion test:** Delete `backendFor`, every six-method forwarding literal, the old positional `DownstreamManager` plus optional-manager `handleServerTool` signature, engine selection trees, optional-slot padding, and child positional dispatch. Deleting the new Module must then recreate seven-way selection in progressive, direct, `listCompletionTools` completion-discovery, and nested callers; deleting `runtime.mcp` must break MCP resource, prompt, and completion behavior rather than silently moving it into common dispatch. +- **Verification:** U2 is complete when the new Interface suite covers all seven Adapters and six common operations, the exported named runtime preserves real MCP-only operations, migrated integration suites retain their behavior, no common caller switches on backend kind, U1 result variants remain unmodified, and the `@caplets/core` Changeset documents the intentional `handleServerTool` Interface break. + +### U3. Make Caplets Exposure Projection registration-ready + +- **Goal:** Deepen Caplets Exposure Projection so MCP and Attach Adapters render registration and Code Mode callable identity directly from one adapter-neutral Interface. +- **Requirements:** R1-R2, R9-R10, R20-R21; F3; AE5. +- **Dependencies:** U2. +- **Files:** + - **Modify:** `packages/core/src/exposure/projection.ts`, `packages/core/src/exposure/discovery.ts`, `packages/core/src/serve/session.ts`, `packages/core/src/attach/api.ts`, `packages/core/src/native/service.ts`, `packages/core/src/engine.ts` + - **Tests:** `packages/core/test/exposure-projection.test.ts`, `packages/core/test/serve-session.test.ts`, `packages/core/test/code-mode-mcp.test.ts`, `packages/core/test/attach-api.test.ts`, `packages/core/test/exposure-discovery.test.ts`, `packages/core/test/native.test.ts` + - **Release:** `.changeset/*.md` +- **Approach:** + - Make each Projection entry kind carry the source registration facts already present in discovery: resolved visible and source Caplet identity, schemas, annotations, resource metadata, prompt arguments, downstream route identity, shadowing, and Code Mode declaration guidance where relevant. + - Keep availability policy in exposure discovery: disabled, setup-required, missing Project Binding context, Project Binding Quarantine, discovery failure, and empty surface. Keep sanitized hidden breadcrumbs and declarative routes in Projection. + - Remove the raw `ExposureSnapshot` parameter from `CapletsMcpSession.reconcileFromProjection` and delete its Code Mode find plus progressive/tool/resource/template/prompt maps. Render one projected entry at a time and attach engine callbacks locally. + - Make reload reconciliation generation-safe: retain the last resolved Projection for presentation, but bind every registration and callback to the matching monotonic engine/config generation and fail callback execution closed after the engine changes until that generation’s Projection resolves. Sequence refreshes and discard any out-of-order completion so an older discovery result cannot replace a newer Projection. Remove immediate `staticExposureSnapshot(...)` reconciliation. + - Feed only the last resolved, generation-matched projected Code Mode identities into both `EngineNativeCapletsService` construction paths and `codeModeNativeTools`. Delete `currentExposureSnapshot()`, `lastExposureSnapshot`, and every `enabledServers()` fallback so initial, failed, hidden, direct-only, or quarantined identities cannot enter native declarations or the sandbox allowlist; `exposureSnapshot()` returns each discovery result directly rather than retaining a second raw authority. + - Keep `buildAttachProjection` as an Adapter renderer. Revision hashing, export IDs, route maps, stale checks, URI decoding, completion normalization, and engine invocation remain Attach-local. + - Do not expand this unit into native local/remote merge lifecycle work; U7 owns the relevant Native Project Binding lifetime change. +- **Execution note:** Strengthen Projection Interface fixtures across all seven kinds before deleting MCP rehydration maps and Code Mode fallback. +- **Patterns to follow:** `discoverExposureSnapshot`, existing safe diagnostics in `buildExposureProjection`, prior decisions in `docs/plans/2026-07-01-001-refactor-caplets-exposure-projection-plan.md`, and Attach’s current projection-only manifest input. +- **Test scenarios:** + - **Happy:** One mixed discovery snapshot projects progressive, Code Mode, direct tool, resource, resource template, prompt, and completion entries with every registration fact needed by MCP and Attach; callbacks are absent. + - **Happy:** MCP registers progressive and direct tools, concrete resources, templates, and prompts from Projection; `code_mode` describes and executes exactly the ready projected Code Mode identities; Attach renders all seven kinds with stable routes and revision. + - **Edge:** Preserve skipped surface discovery for non-direct MCP, `direct_and_code_mode`, prompt arguments, resource URI versus downstream URI, MIME and size, allow/forbid/namespace shadowing, reload removal/update, and initial registration followed by resolved refresh. + - **Failure:** Disabled, setup-required, missing-context, quarantined, discovery-failed, and empty-surface Caplets remain non-callable with sanitized breadcrumbs; stale or wrong-kind Attach exports fail before engine execution; an empty refreshed Code Mode set removes the tool rather than using enabled config; native Code Mode fails closed before initial discovery and after failed refresh; callback invocation during delayed reload rejects a prior-generation registration rather than executing new same-ID configuration. + - **Integration:** Through HTTP Attach manifest/invoke, prove reload changes revision and prevents an old export from invoking a newly hidden Caplet. Through MCP, delay discovery during a same-ID backend/exposure change and prove prior-generation callbacks fail closed until the matching Projection reconciles. Resolve two overlapping refreshes newest-first and prove the older completion is discarded. Through native Code Mode, prove hidden and quarantined handles remain absent before initial discovery, after failed refresh, and after resolved refresh. +- **Deletion test:** Delete all MCP raw-snapshot lookup maps, the engine’s `currentExposureSnapshot()`/`lastExposureSnapshot` cache, native and Code Mode snapshot/config/`enabledServers()` fallbacks, optimistic `staticExposureSnapshot(...)` reload reconciliation, and duplicate refresh authority. Deleting Projection generation binding afterward must recreate stale-callback and out-of-order-refresh guards; deleting Projection itself must recreate ordering, registration facts, completion derivation, route identity, shadowing, and diagnostic sanitization in MCP, Attach, and native rendering. +- **Verification:** U3 is complete when Projection tests own policy, MCP/Attach/native tests own only rendering and route handoff, prior-generation callbacks and out-of-order refreshes fail closed, Code Mode has no alternate identity source, hidden diagnostics remain safe, and the `@caplets/core` Changeset describes the corrected callable-surface behavior. + +### U4. Return Caplets Vault quarantine outcomes instead of parsing recovery prose + +- **Goal:** Keep Vault quarantine facts at the config Seam as structured outcomes and let Setup, Doctor, and generic warning presentation consume them without a fabricated Adapter seam. +- **Requirements:** R1-R2, R11-R12, R20; F4; AE6. +- **Dependencies:** U3 as the fixed delivery predecessor; no runtime dependency on Projection. +- **Files:** + - **Modify:** `packages/core/src/config.ts`, `packages/core/src/cli.ts`, `packages/core/src/cli/doctor.ts` + - **Tests:** `packages/core/test/config.test.ts`, `packages/core/test/cli.test.ts`, `packages/core/test/doctor-cli.test.ts` +- **Approach:** + - Make Vault-origin local-overlay warnings distinguishable by a structured discriminant and attach the facts already present in `quarantineUnresolvedReferenceCaplets`: source kind and path, affected Caplet ID, reference path and name, optional remapped stored key and effective key, resolution reason, recovery target, and recoverable quarantine state. + - Produce that outcome in the same loop that removes the affected Caplet. Keep `formatVaultReferenceWarning` as the config-owned human formatter and preserve its current text and command ordering. + - Update `vaultSetupStatusesForInstalled` to select structured Vault outcomes by discriminant and Caplet ID, preserving ready, unresolved, and unknown states plus ordered deduplicated recovery commands and current messages. + - Update Doctor’s Vault section to map the same outcome into its JSON and Markdown presentation. Derive global versus remote target from data, not the presence of `--remote` in copy. + - Leave engine, native, and generic CLI callers that only print warning messages as presentation-only consumers. +- **Execution note:** Extend current prose-based fixture coverage to assert structured facts first, then remove both parsers only after Setup and Doctor output parity is established. +- **Patterns to follow:** `ConfigVaultResolution`, source-aware overlay loading, current affected-sibling quarantine tests, and Doctor’s existing safe JSON/Markdown result shapes. +- **Test scenarios:** + - **Happy:** A granted reference interpolates with no outcome; one unresolved reference emits one typed recoverable outcome, removes only its Caplet, and preserves healthy siblings and source maps; Setup and Doctor render the same recovery behavior as today. + - **Edge:** Cover global config, project config, Markdown Caplet file, all four reasons, multiple unresolved references, remapped stored key versus reference name, public metadata remaining literal, ordered command deduplication, and global versus remote target. Vary warning prose and punctuation while holding the structured outcome fixed and assert Setup and Doctor return identical status, key, target, and recovery command facts. + - **Failure:** Loader failure leaves requested Setup statuses unknown and Doctor non-OK without fabricated issues; invalid key source suggests Doctor rather than set/grant; no raw Vault value or sensitive resolver detail enters outcome, copy, JSON, or Markdown. + - **Integration:** Install, restore, and update paths that attach Vault setup statuses continue non-blocking behavior; runtime and native config loaders still print current warning prose; remote-target config loading retains `--remote` presentation without giving Access Clients Vault administration authority. +- **Deletion test:** Delete Setup’s message substring and regex extraction and delete Doctor’s `vaultIssueFromWarning`. Do not leave a prose fallback. Deleting the producer-owned structured outcome afterward must force both consumers to reconstruct the same facts. +- **Verification:** U4 is complete when config tests prove structured facts and narrow quarantine, Setup and Doctor outputs remain behaviorally stable, punctuation changes no longer affect classification, all parser branches are gone, and no docs, generated artifacts, or Changesets entry is needed. + +### U5. Move Current Host administration behind a deep operations Module + +- **Goal:** Concentrate shared Current Host policy and outcomes behind one Interface consumed by the human dashboard and Operator bearer Adapters while preserving their distinct authentication and presentation. +- **Requirements:** R1-R2, R13-R15, R20-R21; F5; AE7. +- **Dependencies:** U4. +- **Files:** + - **Create:** `packages/core/src/current-host/operations.ts`, `packages/core/test/current-host-administration.test.ts`, `apps/dashboard/src/lib/ephemeral-reveal.ts`, `apps/dashboard/src/lib/ephemeral-reveal.test.ts` + - **Modify:** `packages/core/src/serve/http.ts`, `packages/core/src/dashboard/catalog.ts`, `packages/core/src/remote-control/dispatch.ts`, `packages/core/src/remote-control/types.ts`, `packages/core/src/dashboard/activity-log.ts`, `apps/dashboard/src/lib/api.ts`, `apps/dashboard/src/components/DashboardApp.tsx` + - **Tests:** `packages/core/test/dashboard-session.test.ts`, `packages/core/test/dashboard-activity.test.ts`, `packages/core/test/dashboard-api.test.ts`, `packages/core/test/dashboard-catalog.test.ts`, `packages/core/test/dashboard-vault.test.ts`, `packages/core/test/dashboard-runtime.test.ts`, `packages/core/test/serve-http.test.ts`, `packages/core/test/remote-control-dispatch.test.ts`, `apps/dashboard/src/lib/api.test.ts`, `apps/dashboard/src/lib/ephemeral-reveal.test.ts` + - **Browser QA:** Run the live dashboard Raw Vault Reveal ceremony, confirm the secret appears only after explicit confirmation, wait through the configured reveal TTL, and verify it disappears without persistence across refresh or navigation. + - **Documentation / release:** `docs/architecture.md`, `.changeset/*.md` +- **Approach:** + - Add a Current Host administration operations Module whose Interface accepts a trusted host-scoped Operator principal and a semantic Current Host query or command, then returns a structured outcome. + - Move eligible host read-model construction, catalog and Caplet administration mapping, pending-login and remote-client mutations, safe Vault administration, activity emission, redaction, and `sessionEnded` decisions into the Module. + - Use existing `CapletsEngine`, `RemoteServerCredentialStore`, catalog/control collaborators, `FileVaultStore`, and `DashboardActivityLog` inside the Implementation; do not expose those collaborators through the Interface. Keep `DashboardSessionStore` in the human Adapter rather than coupling browser session ceremony to the shared Module. + - Keep dashboard login/Pending Remote Login ceremony, cookies, CSRF, `DashboardSessionStore` validation/deletion, route paths, HTTP status/JSON mapping, browser state, and cookie expiry in the human Adapter. It receives the acting principal from `DashboardSessionStore.validate`; the Module computes `sessionEnded`, and the Adapter consumes it to delete the acting session and expire the cookie. + - In configured `development_unauthenticated` mode, synthesize the existing trusted host-local development Operator principal only when the HTTP Adapter verifies a loopback listener. Never derive actor identity from request fields; fail dashboard and Current Host administration closed on non-loopback unauthenticated listeners; real remote `/v1/admin` requests still require an exact Operator bearer. + - Change the Operator bearer Adapter so successful token validation retains the validated client principal instead of discarding it. Keep missing/invalid bearer and Access Client rejection local. Retain `remote-control/dispatch.ts` command-selection branches for `install`, `update`, and `vault_*` as the compatibility-preserving Adapter from existing `RemoteCliRequest` values to the operations Module, while deleting their delegated orchestration bodies. + - Keep generic engine execution, MCP/Attach/Project Binding operations, auth flow mechanics, credential-owner self-revoke, and non-administration remote commands outside this Module. Split role-neutral authenticated-client middleware from exact Access-route middleware so both Access and Operator Clients can revoke only their own credential while Operator bearers remain barred from Access-only runtime routes. + - Leave Raw Vault Reveal entirely in the dashboard Adapter. Retain exact confirmation, no-store response, ephemeral UI display, and generic remote-control rejection. + - Extract the Raw Vault Reveal expiry into a scheduler-injected dashboard helper consumed by `DashboardApp`; fake-timer behavior tests prove replace/cancel/expire semantics without adding a DOM test stack, while live browser QA proves the UI actually wires the helper and never persists the value. +- **Execution note:** Establish direct operations-Interface tests with in-memory or filesystem-local collaborators before replacing route orchestration. Migrate one semantic operation family at a time inside the unit, but do not land a partial dual path. +- **Patterns to follow:** `DashboardSessionStore.validate`, `DashboardActivityLog` redaction and bounds, `RemoteServerCredentialStore` role checks, existing dashboard structured routes, and `dispatchRemoteCliRequest` safe error envelopes. +- **Test scenarios:** + - **Happy:** An Operator principal reads Current Host summary and safe state; installs or updates a catalog Caplet; approves or denies a Pending Remote Login; revokes or changes a client role; and sets, grants, revokes, or deletes safe Vault state with normalized outcomes and actor-attributed activity. + - **Edge:** Cover expired approval, unknown client, catalog local-modification/risk conflict, bounded activity pagination, redacted origin and source facts, an Operator administratively revoking or demoting itself, another Operator’s revocation, both Access and Operator credential-owner self-revoke through the role-neutral route, verified-loopback `development_unauthenticated` synthesis of the trusted host-local development principal while ignoring request actor data, and `sessionEnded` only for the acting principal. + - **Failure:** Missing or malformed principal, Access Client administration, Operator Client MCP/Attach/Project Binding access, cross-client use of credential self-revoke, and non-loopback unauthenticated dashboard/admin requests fail before mutation; invalid semantic input and unavailable control context map to current safe errors; session-store lock contention stays service-unavailable rather than becoming logout; failure activity contains no secret, token, path, raw payload, or duplicate success entry. + - **Integration:** Exercise one representative read and mutation through both a loopback dashboard session and exact Operator bearer and compare policy/outcomes/activity; preserve dashboard cookie and CSRF behavior; prove `/v1/admin` rejects missing bearer and Access Client, bearer-authenticated MCP/Attach/Project Binding routes reject Operator clients, both Access and Operator Clients can revoke only their own credential through `DELETE /v1/remote/client`, configured `development_unauthenticated` runtime routes remain usable without an Access principal, and non-loopback unauthenticated dashboard/admin and Raw Vault Reveal fail closed; preserve durable invalidation when the backing Operator Client is revoked or demoted. + - **Integration:** Raw Vault Reveal still requires the dashboard confirmation path, returns no-store, logs only redacted confirmation metadata, and is rejected when forged through generic bearer remote control. Focused browser QA proves the revealed value disappears after the configured UI timer and does not persist across refresh or navigation. +- **Deletion test:** Delete eligible route-local orchestration, dashboard catalog’s generic dispatch bridge, duplicated redaction/activity/session-ended calculations, and the delegated `installCaplets` / `updateCapletsFromLockfile` / `dispatchVault` orchestration bodies. Retain `/v1/admin` command-selection branches as the Operator bearer Adapter, separate role-neutral self-revoke from exact Access-route authentication, and keep generic Raw Vault Reveal rejection. Deleting the operations Module afterward must recreate policy and outcome mapping across both Adapters. +- **Verification:** U5 is complete when direct Interface tests cover policy without login ceremony, both Adapters prove authorization and serialization parity, Operator bearer activity records the real client ID, exact role middleware preserves both roles’ credential-owner self-revoke without cross-client authority, Raw Vault Reveal remains isolated and passes its live expiry/persistence browser check, configured development runtime access remains compatible while non-loopback administration fails closed, no duplicate activity path remains, architecture text names the Module, and the `@caplets/core` Changesets entry records observable administration behavior. + +### U6. Enforce Anonymous Telemetry at PostHog’s final-payload Seam + +- **Goal:** Make `@caplets/web-observability` own provider-aware final-payload sanitization and no-throw capture policy while each public site retains local SDK initialization. +- **Requirements:** R1-R2, R16-R17, R20-R21; F6; AE8-AE9. +- **Dependencies:** U5 as the fixed delivery predecessor; no runtime dependency on Current Host administration. +- **Files:** + - **Create:** `packages/web-observability/src/posthog.ts`, `packages/web-observability/test/posthog-provider-contract.test.ts` + - **Modify:** `packages/web-observability/src/privacy.ts`, `packages/web-observability/src/index.ts`, `apps/landing/src/scripts/observability.ts`, `apps/docs/src/scripts/observability.ts`, `apps/catalog/src/scripts/observability.ts`, `docs/product/anonymous-telemetry.md`, `docs/product/telemetry-provider-readiness.md`, `apps/docs/src/content/docs/privacy/indexing.mdx` + - **Tests:** `packages/web-observability/test/web-observability.test.ts`, `apps/landing/test/observability.test.ts`, `apps/docs/test/observability.test.ts`, `apps/catalog/test/observability.test.ts` +- **Approach:** + - Add a PostHog-specific Module with two responsibilities behind one small Interface: sanitize the final augmented capture envelope or drop it, and dispatch typed categorical events through an app-supplied capture capability without throwing. + - Keep `buildWebEvent` as the pre-SDK categorical authoring check. Keep Sentry filtering separate because it has a different provider shape and no evidence supports a provider-neutral sanitizer. + - At the final PostHog hook, preserve top-level event identity/timing fields, `properties.token`, anonymous `properties.distinct_id`, and approved categorical properties. Enforce `$process_person_profile: false` and `$geoip_disable: true` independently on every accepted event, overwriting absent, opposite, or malformed values. Remove raw current/referrer URLs, page title, `$set`, `$set_once`, `$unset`, known person-profile fields, unknown application properties, and unapproved SDK additions. + - Treat the public token and anonymous `distinct_id` as required transport identity, not user authentication or permission to retain person data. Do not carry that identity into CLI or runtime telemetry. + - Keep local initialization but pass the shared no-throw sanitizer as `before_send` in the initial `posthog.init` options together with `advanced_disable_flags: true`, `save_referrer: false`, and `save_campaign_params: false`, so PostHog cannot issue pre-hook `/flags` traffic containing token, anonymous identity, or in-memory initial URL/referrer facts. Wrap each init call so synchronous failure leaves analytics disabled and allows module evaluation, pageview setup, and listeners to continue; set the local `posthogEnabled` guard only after `posthog.init` returns successfully. + - Route all three site capture functions through the shared best-effort dispatch policy. Synchronous capture failure is swallowed without affecting the user interaction. + - Delete `filterPostHogProperties` and its property-only test; do not leave an alias, direct per-site filter, or direct unguarded capture path. + - Correct public privacy copy to prohibit known-user/person-profile attribution, user credentials, management keys, and token-shaped application data while stating that the configured public PostHog project token and anonymous `distinct_id` remain solely for provider routing. Retain stronger exclusions for raw URLs, source identity, Caplet identity, raw search text, replay, and identity handoff to runtime telemetry. + - Correct provider readiness documentation and release evidence: the active production and preview PostHog projects must use **Settings → Project → General → IP data capture configuration → Discard**. `$geoip_disable` remains required to suppress GeoIP enrichment but is not evidence that request-source IP storage is disabled. The release owner records the verified setting and review date; U6 cannot complete if that external setting cannot be confirmed. +- **Execution note:** Start with the focused provider-contract fixture at the pinned PostHog version, then prove the installed hook and failure boundary independently in all three site Adapters. +- **Patterns to follow:** Existing categorical allowlists and `buildWebEvent`, Sentry’s total filtering style, current local Astro/Starlight/catalog initialization, and the privacy decisions in `docs/plans/2026-06-28-002-feat-telemetry-observability-loop-plan.md`. +- **Test scenarios:** + - **Happy:** A realistic augmented final envelope retains event, UUID/timestamp, public token, anonymous `distinct_id`, and valid categorical properties; the sanitizer sets `$process_person_profile` to `false` and `$geoip_disable` to `true`, and all raw URL, referrer, title, person mutation, person profile, and unknown app fields are absent. + - **Edge:** Null or malformed payload drops without throwing; absent, `true`, string, numeric, or otherwise malformed `$process_person_profile` becomes literal `false`; absent, `false`, string, numeric, or otherwise malformed `$geoip_disable` becomes literal `true`; unknown `$` properties are not admitted by prefix; nested objects and arrays cannot enter categorical fields; absent env performs no init or capture; the provider SDK version fixture documents the exact required transport allowlist. + - **Failure:** In each landing, docs, and catalog suite, cover three independent synchronous throws: `posthog.init` leaves analytics disabled while module evaluation and listeners continue; an injected sanitizer/hook exception inside the installed `before_send` closure returns `null` without escaping; `posthog.capture` is absorbed while the click/search/copy interaction continues. Malformed payload drop remains a separate edge case. + - **Integration:** Capture the `before_send` hook and init options passed by each site’s local `posthog.init`; assert flags, referrer persistence, and campaign capture are disabled and the provider fixture makes no `/flags` request. Feed the same augmented payload through each installed hook and assert token plus anonymous `distinct_id` survive while prohibited fields disappear. Preserve each site’s categorical event behavior and keep catalog’s Sentry server path separate. + - **Documentation:** Review product and public privacy content to ensure they distinguish the configured public provider project token plus anonymous transport identity from user credentials and known-user/person attribution and state that no browser identity is handed into CLI/runtime telemetry. Review provider readiness evidence to confirm production and preview PostHog projects discard IP data and do not claim `$geoip_disable` controls source-IP storage. Do not add exact-copy assertions for narrative privacy prose; provider-envelope and installed-hook integration tests own the durable code behavior. +- **Deletion test:** Delete the unused property-only filter, three direct dispatch implementations, and any unguarded initialization path. Deleting the new Module afterward must recreate final allowlisting, provider-envelope preservation, drop behavior, and non-blocking dispatch in all three site Adapters. +- **Verification:** U6 is complete when the shared and focused provider-contract suites prove the exact final payload, all three app suites prove init/hook/capture failure isolation and installed-hook behavior, public privacy wording is accurate, the release owner has recorded production/preview PostHog **Discard IP data** verification, private package type/build gates pass, and no dependency, lockfile, generated artifact, or Changesets change is needed. + +### U7. Deepen Native Project Binding lifecycle ownership across both remote Adapters + +- **Goal:** Give common Native Project Binding lifecycle invariants one owner while keeping Cloud and self-hosted protocol and failure policies explicit in their Adapters. +- **Requirements:** R1-R2, R18-R21; F7; AE10. +- **Dependencies:** U6 as the fixed delivery predecessor and U3 for stable Projection and Project Binding Quarantine authority. +- **Files:** + - **Create:** `packages/core/src/native/project-binding-lifecycle.ts` + - **Modify:** `packages/core/src/native/service.ts`, `packages/core/src/cloud/presence.ts`, `packages/core/src/cloud/client.ts`, `packages/core/src/serve/http.ts` + - **Tests:** `packages/core/test/cloud-presence.test.ts`, `packages/core/test/native-remote.test.ts`, `packages/core/test/serve-http.test.ts`, `packages/core/test/project-binding-integration.test.ts` + - **Release:** `.changeset/*.md` +- **Approach:** + - Add a Native Project Binding lifecycle owner at the existing native composition Seam. Its Interface owns composite ordering and race guards for start, allowed-Caplet update, remote replacement, and close. + - Preserve creation authority in native option/profile resolution. Cloud mode constructs `ProjectBindingSessionManager` with `CapletsCloudClient`; eligible self-hosted loopback mode constructs the self-hosted Binding Session Adapter for the Attach project-binding routes. + - Keep Cloud registration, heartbeat, update, stop, and `onError` behavior in `packages/core/src/cloud/presence.ts` and `packages/core/src/cloud/client.ts`. A Cloud heartbeat error reports and remains active; do not force disconnect or re-registration. + - Keep self-hosted session creation, heartbeat, delete, exact legacy unsupported detection, disconnect, and later re-registration in its Adapter. Moving that class out of the overloaded `native/service.ts` Implementation is allowed inside the new Module, but its policy must remain explicit and independently tested. + - Make the owner capture the authoritative local allowed-Caplet set on initial load and consume every successful source update from both `CompositeNativeCapletsService.reload` and `DefaultNativeCapletsService`’s watch-driven `local.onToolsChanged`. Serialize Adapter update calls per active lifecycle, deduplicate unchanged IDs, and coalesce rapid pending updates to the newest accepted set; an older in-flight update must settle before the one newest pending set is sent, so older completion can never overwrite newer remote IDs. Retain the last accepted set without Adapter update when a source update fails. Supply the latest accepted set to every initial or replacement registration, including updates accepted before or during deferred start. The owner also enforces no stop before successful start, one active start, close-old-before-start-new replacement, close-during-replacement suppression, timer cancellation, and idempotent close. + - Require each Cloud and self-hosted Adapter to run heartbeat, allowed-ID update, and cleanup remote mutations through one serialized state-mutation chain. Accept no new mutation after closing or replacement starts; drain the in-flight mutation and the one coalesced latest pending allowed-ID update accepted before close, then issue final stop/delete so cleanup is the last remote mutation. Never permit parallel heartbeat/update writes whose completion order can regress remote state. + - Make replacement a commit-after-cleanup transaction: keep the old Adapter installed while its final cleanup runs, install/start the new Adapter only after cleanup succeeds, and on stop/delete failure retain the old Adapter in a cleanup-failed terminal state that accepts no heartbeat/update but permits cleanup retry. Dispose the never-started candidate locally without a remote stop. A failed replacement never leaves a new Adapter installed-but-unstarted or mixed old/new ownership. + - Preserve fire-and-forget startup diagnostics and the availability of local and non-project remote Caplets when binding startup fails. Project Binding Quarantine and callable-surface hiding remain owned by discovery/Projection and call guards. + - Replace the Cloud-borrowed `NativeProjectBindingManager` type with the lifecycle owner’s Interface. Migrate tests from `LocalPresenceManager` to `ProjectBindingSessionManager` and delete both `LocalPresenceManager` aliases rather than preserving a third-Adapter impression. + - Keep foreground `runProjectBindingSession`, Mutagen, workspace leases, Remote Attach polling/reconnect, Remote Profile selection, namespace merge, telemetry, and generic tool execution outside the lifecycle owner. Harden the self-hosted HTTP/WebSocket Adapter separately: at upgrade, retain the authenticated durable Client ID; before every active socket control message, read that client’s current credential-store record and require non-revoked exact `access` role and matching binding ownership. Do not revalidate the handshake token on every message because token rotation must not kill a still-authorized client; exempt the existing `development_unauthenticated` sentinel path. + - Serialize every self-hosted HTTP/WebSocket mutation through one per-record queue with explicit origin. For a client heartbeat, snapshot the authoritative record’s current generation and pre-mutation `expiresAt`, validate current non-revoked exact `access` role, ownership, Map identity, active state, and unexpired lease, then build a candidate next record/lease without mutating the authoritative object. After any awaited candidate persistence/network write, re-read credentials and re-check Map identity, active state, captured generation, and the captured pre-mutation expiry against the new current time; only then copy candidate fields into the authoritative record and acknowledge. If authority or the original lease expires while the write is in flight, never commit the candidate: run trusted server-enforced terminal cleanup in the same queue, remove/deactivate the authoritative record and any candidate lease, then close the socket. Trusted cleanup triggered by revocation, demotion, auth failure, expiry, socket error, prune, or server close bypasses the failed client-role check but still requires captured ownership plus authoritative Map identity and serializes behind started work. No queued or stale client work can set `active = true` again. + - Route lease expiry and `pruneExpiredProjectBindingSessions` through trusted terminal cleanup on the same per-record queue; the prune timer never deletes the Map directly. Client heartbeat/end requires `expiresAt > now` before mutation and again after awaited writes. A post-TTL message cannot renew a record merely because the next prune tick has not run, and prune racing an in-flight write remains terminal. +- **Execution note:** Characterize shared ordering with injected recording Adapters first, then move Composite calls behind the owner, then move/delete aliases and distributed lifecycle calls. Use deterministic fake timers for heartbeat and close races. +- **Patterns to follow:** `CompositeNativeCapletsService.replaceRemote` close-before-start ordering, `ProjectBindingSessionManager` timer injection, self-hosted session integration in `packages/core/test/native-remote.test.ts`, and Access Client ownership tests in `packages/core/test/serve-http.test.ts`. +- **Test scenarios:** + - **Happy:** Initial composite creation captures the authoritative local allowed-Caplet set and starts once; successful explicit reload and a watch-driven local tool-set change each update the active binding with the new set once; Cloud keeps PATCH behavior, self-hosted keeps cached-ID/heartbeat behavior; remote replacement closes the old lifecycle before starting the new one; normal close tears down one active lifecycle. + - **Edge:** Concurrent starts coalesce; unchanged IDs from either source deduplicate; any failed source update retains the last accepted set and emits no Adapter update; updates before or during a deferred start become the registration’s authoritative initial set for both Adapters; rapid allowed-ID sets A, B, then C while A is deferred start only A and then coalesced C, with C as final remote state; close queues behind accepted state work; close while start is pending performs no remote stop without an ID; close during replacement prevents new start; repeated close is a no-op; timers stop before in-flight mutations drain. Rotating an active Access Client’s token does not close its authorized socket because message authorization follows durable Client ID state, not the stale handshake token. + - **Failure:** Start failure remains a scoped diagnostic and preserves unrelated tools; Cloud heartbeat failure calls its reporter and does not re-register; self-hosted 503 can retry on a later lifecycle start; exact legacy unsupported latches silently; another unsupported error reports and remains retryable; self-hosted heartbeat failure disconnects and a later start creates a fresh session; heartbeat/update rejection during closing cannot reactivate or mutate a cleaned-up binding. Revocation, demotion to Operator, missing durable client state, or ownership mismatch after WebSocket upgrade closes the socket with a policy code, ends its binding record, and cannot renew the lease. + - **Failure:** Open two sockets for one binding, race one socket’s end/close against the other’s heartbeat, and cover both orderings. Only the authoritative active record may mutate; terminal cleanup wins, the Map record and active lease cannot be recreated, and all later messages on the stale socket close with a policy error. + - **Failure:** Defer one record mutation, queue a later heartbeat, revoke or demote the owning Access Client before the queued heartbeat runs, then release the queue. The queued heartbeat re-reads authorization at execution time, closes with a policy error, and cannot update state, renew the lease, or reactivate the binding. + - **Failure:** Start an authorized heartbeat and defer its lease write, revoke or demote the client while that write is in flight, then release it. Post-write revalidation fails, the heartbeat is not acknowledged, trusted in-queue cleanup deactivates/removes the record and lease despite the now-failed Access role, and no later stale message can recreate it. + - **Failure:** Make the old Cloud stop or self-hosted delete fail during replacement. The candidate never starts or becomes current, the old Adapter enters cleanup-failed with no further heartbeat/update, retry can attempt cleanup again, and no mixed owner state, leaked timer, or installed-but-unstarted new presence remains. + - **Failure:** Advance fake time past `expiresAt` but stop before the prune interval fires, then send a heartbeat. In-queue expiry validation rejects it and trusted cleanup removes/deactivates the record and lease rather than extending the TTL. + - **Integration:** Defer `ProjectBindingWorkspaceStore.writeLease` after an authorized heartbeat has built but not committed its candidate state, then revoke/demote the owner or advance beyond the captured pre-mutation expiry before releasing the write. Assert the authoritative record never receives the candidate TTL/state, the socket closes, server-enforced cleanup removes or terminalizes the candidate lease and Map record, the heartbeat receives no success, and a second socket’s later heartbeat cannot revive the binding. + - **Integration:** In `native-remote.test.ts`, a watch-driven `local.onToolsChanged` update changes the binding without explicit `Composite.reload`, unchanged IDs do not repeat the update, failed source updates retain the last accepted set, and updates before/during deferred start seed the eventual registration. Use deferred heartbeat/update operations to prove replacement or close drains them and makes stop/delete the final remote mutation. Through the real self-hosted HTTP Adapter, an owning Access Client connects, a second exact Operator revokes or demotes it through Current Host administration, the next socket heartbeat is denied and the lease is not renewed; token rotation alone preserves the socket; cross-client ownership and an Operator bearer are denied; configured development runtime access remains unchanged; `/v1/admin` stays exact Operator-only. Cloud preserves project files, PATCH updates, and offline close. + - **Integration:** Through the real WebSocket server, connect two sockets to one owned binding, defer the first heartbeat write, close/end the peer, then release the heartbeat and try another message from the stale peer. Assert the end transition is the final persisted state, the binding is absent/inactive, cleanup does not retain its workspace as an active lease, and no stale object reference can renew it. + - **Integration:** Through the real credential store and WebSocket server, hold the per-record queue with a deferred first mutation, enqueue a heartbeat, revoke or demote the durable client, then release the first mutation. Assert the queued heartbeat performs no write and the final lease timestamp/state are unchanged by post-revocation work. + - **Integration:** Defer `ProjectBindingWorkspaceStore.writeLease` after an authorized heartbeat begins, revoke/demote the owner through Current Host administration, then release the write. Assert the socket closes, server-enforced cleanup removes or terminalizes the persisted lease and Map record, the heartbeat receives no success, and a second socket’s later heartbeat cannot revive the binding. + - **Integration:** For both Cloud and self-hosted Adapters, defer allowed-ID update A, enqueue B then C from mixed watch and explicit sources, and request close. Assert B is coalesced away, C starts only after A settles, cleanup starts only after C, and the final remote ID set observed before terminal cleanup is C rather than stale A or B. + - **Integration:** Defer and reject old Adapter cleanup during replacement; assert the composite still identifies only the cleanup-failed old owner, the candidate has no remote start/stop, retrying cleanup is possible, and only a later successful cleanup permits one new start. + - **Integration:** With fake time and a deferred workspace write, cover both post-TTL/pre-prune heartbeat and prune-versus-in-flight-heartbeat orderings through the real HTTP/WebSocket Adapter. Assert the Map record, persisted lease, socket result, and workspace-retention decision all remain terminal after expiry. + - **Quarantine integration:** Binding transport degradation never hides healthy local or non-project remote Caplets and never creates a second quarantine state outside Caplets Exposure Projection. +- **Deletion test:** Delete distributed `startPresence`, direct optional update/close calls, the Cloud-borrowed manager type, `LocalPresenceManager` aliases, and any duplicate coordinator. Deleting the owner afterward must recreate lifecycle ordering and race guards in composite construction, reload, replacement, and close; deleting either Adapter must recreate its protocol and failure policy in the composite. +- **Verification:** U7 is complete when shared-owner tests prove initial/pre-start/update/replace/close invariants, deduplicated explicit and watch-driven updates, last-good retention, and cleanup-after-in-flight serialization; Cloud and self-hosted suites prove distinct update/failure loops; real HTTP integration proves durable-client revocation/demotion teardown, token-rotation continuity, exact remote-credential role separation, development runtime compatibility, authoritative-record liveness, two-socket end/heartbeat serialization, and no post-revocation or post-end lease renewal; unrelated tool availability remains stable; no foreground or quarantine behavior moved into the owner; and the `@caplets/core` Changeset records the corrected lifecycle behavior. +- **U7 authorization/concurrency verification:** Heartbeats stage candidate state without mutating the authoritative record; queued client work revalidates authorization, captured generation/expiry, and authoritative-record identity before and after awaited writes; trusted server terminal cleanup removes candidate persistence after role loss or expiry; prune uses the same queue; two-socket end/heartbeat and prune/write races are terminal; rapid allowed-ID updates are latest-wins and non-overlapping; replacement commits only after retryable old cleanup; and cleanup is the final remote mutation. + +--- + +## Verification Contract + +Planning was read-only. No formatter, linter, test, build, package-manager, repository-wide verification, commit, or push command was executed while producing this plan. + +| Gate | Units | Required outcome | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Media and result focused suites: `packages/core/test/media-artifacts.test.ts`, `packages/core/test/graphql.test.ts`, `packages/core/test/http-actions.test.ts`, `packages/core/test/result-content.test.ts`, `packages/core/test/downstream.test.ts`, `packages/core/test/tools.test.ts`, `packages/core/test/runtime.test.ts`, `packages/core/test/native.test.ts`, `packages/core/test/caplet-sets.test.ts`, `packages/core/test/serve-http.test.ts`, `packages/core/test/serve-session.test.ts`, `packages/core/test/attach-api.test.ts`, `packages/pi/test/pi.test.ts` | U1, U2 | Three canonical variants, bounded failures, GraphQL error classification, nested-runtime propagation, mixed-block fidelity, transparent dispatch, host capability boundaries, and Pi reference presentation pass through real production paths. | +| Backend dispatch and representative Adapter suites: `packages/core/test/backend-operation-dispatch.test.ts`, `packages/core/test/openapi.test.ts`, `packages/core/test/google-discovery.test.ts`, `packages/core/test/cli-tools.test.ts`, `packages/core/test/caplet-sets.test.ts` | U2 | All seven Adapters and six common operations select correctly; nested dispatch works; no positional or alternate selection path remains. | +| Projection and host-renderer suites: `packages/core/test/exposure-projection.test.ts`, `packages/core/test/exposure-discovery.test.ts`, `packages/core/test/serve-session.test.ts`, `packages/core/test/code-mode-mcp.test.ts`, `packages/core/test/attach-api.test.ts`, `packages/core/test/native.test.ts` | U3 | All seven entry kinds render from Projection; hidden identities never become callable; generation-matched reload, stale callback, overlapping refresh, and native Code Mode behavior remain correct; and no raw snapshot/config fallback or engine snapshot cache exists. | +| Vault outcome suites: `packages/core/test/config.test.ts`, `packages/core/test/cli.test.ts`, `packages/core/test/doctor-cli.test.ts` | U4 | Producer facts, narrow quarantine, Setup status, Doctor JSON/Markdown, targets, remaps, redaction, and loader failures remain correct without prose parsing; changing warning wording or punctuation leaves structured status, key, target, and command facts identical. | +| Current Host Interface and Adapter suites: `packages/core/test/current-host-administration.test.ts`, dashboard test families, `packages/core/test/serve-http.test.ts`, `packages/core/test/remote-control-dispatch.test.ts`, `apps/dashboard/src/lib/api.test.ts`, `apps/dashboard/src/lib/ephemeral-reveal.test.ts`, plus focused live dashboard QA | U5 | Direct policy coverage, two-Adapter parity, exact role-gated remote-credential routes, role-neutral owner-only self-revoke for Access and Operator Clients, loopback-only development administration, development runtime compatibility, non-loopback denial, session invalidation, actor activity, redaction, scheduler-tested and browser-confirmed Raw Vault Reveal expiry/non-persistence all hold. | +| Web observability and provider-contract suites: `packages/web-observability/test/web-observability.test.ts`, `packages/web-observability/test/posthog-provider-contract.test.ts`, and all three app observability suites | U6 | Final payload retains the public provider token and anonymous `distinct_id`, rejects credentials, person mutation, and prohibited fields, and each Adapter survives init, hook, and capture failures without breaking site behavior. | +| Project Binding owner and transport suites: `packages/core/test/cloud-presence.test.ts`, `packages/core/test/native-remote.test.ts`, `packages/core/test/serve-http.test.ts`, `packages/core/test/project-binding-integration.test.ts` | U7 | Initial and watch-driven updates, coalesced latest-wins allowed-ID serialization, cleanup-last ordering, failed-cleanup replacement atomicity/retry, distinct Cloud/self-hosted failure policies, durable-client revocation/demotion teardown, execution-time authorization for queued work, token-rotation continuity, exact remote-credential Access ownership with Operator denial, development runtime compatibility, authoritative Map-record liveness, two-socket terminal-state serialization, timer cleanup, and unrelated Caplet availability all hold. | +| Active Project Binding authorization race suites: deferred queued work, staged candidate lease writes, role mutation, lease expiry/pruning, and two sockets through `packages/core/test/serve-http.test.ts` | U7 | Authoritative state is unchanged before commit; pre/post-write durable-client authorization, captured-generation and pre-mutation-expiry validation, trusted cleanup of candidate persistence after role loss or TTL, serialized pruning, authoritative-record identity, no post-revocation/expiry acknowledgement, and no active lease or Map-record resurrection hold under deterministic interleavings. | +| Package type and build gates for `@caplets/core`, `@caplets/pi`, `@caplets/web-observability`, landing, docs, catalog, and dashboard as touched | U1-U7 | New internal contracts compile across package seams and built consumers use only the clean-cutover Interfaces. | +| Documentation and release checks | U1, U2, U3, U5-U7 | Architecture and privacy docs match shipped behavior; production and preview PostHog project settings have recorded **Discard IP data** evidence; required Changesets entries exist for observable package changes; no unnecessary schema, Code Mode, benchmark, or lockfile artifact changes appear. | +| Standard CI verification and Changesets status on supported Node versions | Whole plan | The repository’s normal CI quality, package, docs, generated-artifact, benchmark, build, and release-policy checks accept the completed seven-commit branch. | + +No test may assert only source text, an implementation literal, or the presence of a helper. Each named behavioral scenario must exercise an observable result, state transition, authorization decision, privacy outcome, or integration handoff; narrative copy is reviewed directly rather than locked by substring tests. + +--- + +## Definition of Done + +- R1-R21 and AE1-AE10 are satisfied by the seven U-ID units with no launch-blocking question left open. +- U1 through U7 exist as seven sequential, independently reviewable, behavior-complete commits in the fixed order. +- U1 is complete before U2, and U2 preserves the canonical U1 result without reclassification or presentation. +- `inline`, `local-artifact`, and `remote-reference` are the only canonical internal Media result discriminants; `remote-reference` has no filesystem path or `pathResolution` anywhere in the internal contract. +- Host capability context alone decides whether a managed path is exposed, and every outer Adapter owns its own presentation. +- Common backend operations have one dispatch owner, all seven manager Adapters are migrated, nested Caplets use the same Module, and MCP-only operations remain separate. +- Caplets Exposure Projection is registration-ready and remains free of callbacks, engine references, auth, sandbox, and transport policy; MCP and Attach no longer rehydrate parallel exposure state. +- Caplets Vault quarantine facts cross the config Seam as data, current warning and CLI/Doctor behavior remains stable, and both regex parser detours are deleted. +- Current Host administration has one operations Interface and two real Adapters; role-gated remote-credential Client Roles are exact while credential-owner self-revoke remains role-neutral, development administration authority is loopback-only while configured development runtime access remains compatible, Operator activity, redaction, session-ended outcomes, non-loopback denial, and dashboard-only Raw Vault Reveal expiry/non-persistence are verified. +- PostHog final-payload policy is installed in landing, docs, and catalog; token and anonymous `distinct_id` survive; person mutation, raw URL, and unknown application fields do not; init, hook, and capture failures are non-blocking in every site; and production/preview provider readiness records verified **Discard IP data** settings rather than treating `$geoip_disable` as source-IP storage control. +- Native Project Binding has one lifecycle owner and exactly two production Adapters with initial/explicit/watch-driven ID authority, coalesced latest-wins state serialization, cleanup-last mutation ordering, retryable failed-cleanup replacement atomicity, preserved distinct failure policies, execution-time durable-client and authoritative-record reauthorization for queued active-socket work, two-socket terminal-state serialization, exact remote-credential Access authorization with Operator denial, token-rotation continuity, and development runtime compatibility; aliases and distributed orchestration are removed; Project Binding Quarantine remains narrow and Projection-owned. +- Active Project Binding heartbeats stage candidate state and commit only after post-await authorization, generation, authoritative-record, and captured-expiry checks; trusted server cleanup and pruning remove candidate persistence through the same queue after role loss or TTL; no queued, in-flight, post-TTL, or second-socket heartbeat can mutate or resurrect an unauthorized, expired, or ended lease. +- Each unit passes its deletion test. No compatibility shim, deprecated alias, fallback parser, duplicate dispatch, second exposure authority, direct unguarded PostHog path, or alternate lifecycle coordinator remains. +- Focused Interface and Adapter evidence, package type/build evidence, documentation checks, standard CI verification, and Changesets status satisfy the Verification Contract. +- `docs/architecture.md`, `docs/product/anonymous-telemetry.md`, `docs/product/telemetry-provider-readiness.md`, and the public privacy page are updated exactly where required; `CONTEXT.md`, `CONCEPTS.md`, ADRs, config schema, Code Mode generated artifacts, benchmarks, and lockfile remain unchanged unless execution uncovers a direct behavior-authority conflict and the plan is revised before proceeding. +- Required Changesets entries cover U1, U2, U3, U5, and U7 observable package behavior; private-only U6 changes do not create a synthetic release. +- Abandoned experiments, dead helpers, obsolete tests, optional-manager padding, aliases, and other scaffolding from unsuccessful approaches are removed before completion. diff --git a/packages/core/src/caplet-sets.ts b/packages/core/src/caplet-sets.ts index 5fc81202..78011488 100644 --- a/packages/core/src/caplet-sets.ts +++ b/packages/core/src/caplet-sets.ts @@ -48,6 +48,8 @@ export class CapletSetManager { authDir?: string; artifactDir?: string; exposeLocalArtifactPaths?: boolean; + mediaInlineThresholdBytes?: number; + mediaArtifactMaxBytes?: number; ancestry?: Set; } = {}, ) {} @@ -232,6 +234,12 @@ export class CapletSetManager { ...(this.options.exposeLocalArtifactPaths === false ? { exposeLocalArtifactPaths: false } : {}), + ...(this.options.mediaInlineThresholdBytes === undefined + ? {} + : { mediaInlineThresholdBytes: this.options.mediaInlineThresholdBytes }), + ...(this.options.mediaArtifactMaxBytes === undefined + ? {} + : { mediaArtifactMaxBytes: this.options.mediaArtifactMaxBytes }), }; const childAncestry = new Set([...ancestry, cacheKey]); child = { diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index f33c68ab..101c6cab 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -27,7 +27,7 @@ import { } from "./observed-output-shapes"; import type { ProjectBindingExecutionContext } from "./project-binding/execution-context"; import { ServerRegistry } from "./registry"; -import { handleServerTool } from "./tools"; +import { extractArtifacts, handleServerTool } from "./tools"; import { discoverExposureSnapshot, type ExposureSnapshot } from "./exposure/discovery"; import { captureRuntimeReliabilityEvent, @@ -54,6 +54,8 @@ export type CapletsEngineOptions = { authDir?: string; artifactDir?: string; exposeLocalArtifactPaths?: boolean; + mediaInlineThresholdBytes?: number; + mediaArtifactMaxBytes?: number; watchDebounceMs?: number; watch?: boolean; writeErr?: (value: string) => void; @@ -155,7 +157,7 @@ export class CapletsEngine { this.registry, selectHttpLikeOptions(options), ); - this.graphql = new GraphQLManager(this.registry, selectAuthOptions(options.authDir)); + this.graphql = new GraphQLManager(this.registry, selectHttpLikeOptions(options)); this.http = new HttpActionManager(this.registry, selectHttpLikeOptions(options)); this.cli = new CliToolsManager(this.registry, { projectBindingContext: options.projectBindingContext, @@ -779,11 +781,19 @@ function selectHttpLikeOptions(options: CapletsEngineOptions): { authDir?: string; artifactDir?: string; exposeLocalArtifactPaths?: boolean; + mediaInlineThresholdBytes?: number; + mediaArtifactMaxBytes?: number; } { return { ...selectAuthOptions(options.authDir), ...(options.artifactDir ? { artifactDir: options.artifactDir } : {}), ...(options.exposeLocalArtifactPaths === false ? { exposeLocalArtifactPaths: false } : {}), + ...(options.mediaInlineThresholdBytes === undefined + ? {} + : { mediaInlineThresholdBytes: options.mediaInlineThresholdBytes }), + ...(options.mediaArtifactMaxBytes === undefined + ? {} + : { mediaArtifactMaxBytes: options.mediaArtifactMaxBytes }), }; } @@ -876,6 +886,7 @@ function annotateDirectResult(result: unknown, caplet: CapletConfig, operation: return result; } const existingMeta = (result as { _meta?: unknown })._meta; + const artifacts = extractArtifacts(result); return { ...result, _meta: { @@ -885,6 +896,7 @@ function annotateDirectResult(result: unknown, caplet: CapletConfig, operation: backend: caplet.backend, operation, exposure: "direct", + ...(artifacts.length === 0 ? {} : { artifacts }), }, }, }; diff --git a/packages/core/src/google-discovery/manager.ts b/packages/core/src/google-discovery/manager.ts index d16fd9c0..5a9db821 100644 --- a/packages/core/src/google-discovery/manager.ts +++ b/packages/core/src/google-discovery/manager.ts @@ -48,6 +48,7 @@ export class GoogleDiscoveryManager { authDir?: string; artifactDir?: string; exposeLocalArtifactPaths?: boolean; + mediaInlineThresholdBytes?: number; } = {}, ) {} @@ -138,6 +139,9 @@ export class GoogleDiscoveryManager { method: operation.method, ...(this.options.artifactDir ? { artifactDir: this.options.artifactDir } : {}), ...(this.options.exposeLocalArtifactPaths === false ? { exposeLocalPath: false } : {}), + ...(this.options.mediaInlineThresholdBytes === undefined + ? {} + : { maxInlineBytes: this.options.mediaInlineThresholdBytes }), ...(typeof args.filename === "string" ? { filename: args.filename } : {}), ...(typeof args.outputPath === "string" ? { outputPath: args.outputPath } : {}), maxBytes: DEFAULT_MEDIA_RESPONSE_MAX_BYTES, @@ -208,6 +212,9 @@ export class GoogleDiscoveryManager { method: operation.method, ...(this.options.artifactDir ? { artifactDir: this.options.artifactDir } : {}), ...(this.options.exposeLocalArtifactPaths === false ? { exposeLocalPath: false } : {}), + ...(this.options.mediaInlineThresholdBytes === undefined + ? {} + : { maxInlineBytes: this.options.mediaInlineThresholdBytes }), }); return { content: markdownStructuredContent(parsed, { diff --git a/packages/core/src/google-discovery/operations.ts b/packages/core/src/google-discovery/operations.ts index 6988d509..48129dbf 100644 --- a/packages/core/src/google-discovery/operations.ts +++ b/packages/core/src/google-discovery/operations.ts @@ -1,3 +1,4 @@ +import { httpLikeMediaOutputSchema } from "../media/results"; import { googleDiscoverySchemaToJsonSchema } from "./schema"; import type { GoogleDiscoveryDocument, @@ -190,7 +191,7 @@ function scopePreferenceRank(scope: string): number { } function structuredOutputSchema(bodySchema: Record): Record { - return { + return httpLikeMediaOutputSchema({ type: "object", additionalProperties: false, required: ["status", "statusText", "headers"], @@ -207,7 +208,7 @@ function structuredOutputSchema(bodySchema: Record): Record { + graphQlErrorInspector.write(chunk); }, - body, - }; + }); + const graphQlErrors = graphQlErrorInspector.finish(); return { content: markdownStructuredContent(result, { title: `${endpoint.name} call_tool ${toolName}`, @@ -203,9 +214,7 @@ export class GraphQLManager { tool: toolName, }), structuredContent: result, - isError: - !response.ok || - Boolean(body && typeof body === "object" && "errors" in body && (body as any).errors), + isError: !response.ok || graphQlErrors, }; } catch (error) { if (isAbortError(error)) { @@ -721,6 +730,520 @@ function shouldSendSchemaAuth(endpoint: GraphQlEndpointConfig): boolean { ); } +type JsonContainer = + | { + kind: "array"; + state: "valueOrEnd" | "value" | "commaOrEnd"; + isErrorValue: boolean; + } + | { + kind: "object"; + state: "keyOrEnd" | "key" | "colon" | "value" | "commaOrEnd"; + isRoot: boolean; + keyIsErrors: boolean; + isErrorValue: boolean; + }; + +type JsonToken = + | { + kind: "string"; + role: "key" | "value"; + isErrorValue: boolean; + trackKey: boolean; + keyMatches: boolean; + keyLength: number; + hasContent: boolean; + escaping: boolean; + unicode: string | undefined; + } + | { + kind: "literal"; + expected: "true" | "false" | "null"; + index: number; + truthiness: boolean; + isErrorValue: boolean; + } + | { + kind: "number"; + state: + | "sign" + | "zero" + | "integer" + | "fractionStart" + | "fraction" + | "exponentStart" + | "exponentSign" + | "exponent"; + mantissaNonZero: boolean; + isErrorValue: boolean; + }; + +class GraphQlErrorInspector { + private readonly decoder = new TextDecoder(); + private readonly stack: JsonContainer[] = []; + private token: JsonToken | undefined; + private rootState: "value" | "complete" = "value"; + private errorsValue = false; + private valid = true; + private finished = false; + + write(chunk: Uint8Array): void { + if (!this.finished && this.valid) { + this.consume(this.decoder.decode(chunk, { stream: true })); + } + } + + finish(): boolean { + if (!this.finished) { + this.consume(this.decoder.decode()); + this.finishToken(); + if (this.stack.length > 0 || this.rootState !== "complete") { + this.invalidate(); + } + this.finished = true; + } + return this.valid && this.errorsValue; + } + + private consume(text: string): void { + for (const character of text) { + this.consumeCharacter(character); + if (!this.valid) { + return; + } + } + } + + private consumeCharacter(character: string): void { + const token = this.token; + if (token) { + this.consumeToken(token, character); + return; + } + const context = this.stack[this.stack.length - 1]; + if (!context) { + if (this.rootState === "complete") { + if (!this.isWhitespace(character)) { + this.invalidate(); + } + return; + } + this.startValue(character, false); + return; + } + if (context.kind === "object") { + this.consumeObject(context, character); + return; + } + this.consumeArray(context, character); + } + + private consumeObject( + context: Extract, + character: string, + ): void { + if (context.state === "keyOrEnd" || context.state === "key") { + if (this.isWhitespace(character)) { + return; + } + if (character === "}" && context.state === "keyOrEnd") { + this.closeContainer(context); + return; + } + if (character === '"') { + this.startString("key", false, context.isRoot); + return; + } + this.invalidate(); + return; + } + if (context.state === "colon") { + if (this.isWhitespace(character)) { + return; + } + if (character === ":") { + context.state = "value"; + } else { + this.invalidate(); + } + return; + } + if (context.state === "value") { + this.startValue(character, context.isRoot && context.keyIsErrors); + return; + } + if (this.isWhitespace(character)) { + return; + } + if (character === ",") { + context.state = "key"; + } else if (character === "}") { + this.closeContainer(context); + } else { + this.invalidate(); + } + } + + private consumeArray( + context: Extract, + character: string, + ): void { + if (context.state === "valueOrEnd" || context.state === "value") { + if (this.isWhitespace(character)) { + return; + } + if (character === "]" && context.state === "valueOrEnd") { + this.closeContainer(context); + } else { + this.startValue(character, false); + } + return; + } + if (this.isWhitespace(character)) { + return; + } + if (character === ",") { + context.state = "value"; + } else if (character === "]") { + this.closeContainer(context); + } else { + this.invalidate(); + } + } + + private startValue(character: string, isErrorValue: boolean): void { + if (this.isWhitespace(character)) { + return; + } + if (character === "{") { + this.stack.push({ + kind: "object", + state: "keyOrEnd", + isRoot: this.stack.length === 0 && this.rootState === "value", + keyIsErrors: false, + isErrorValue, + }); + return; + } + if (character === "[") { + this.stack.push({ kind: "array", state: "valueOrEnd", isErrorValue }); + return; + } + if (character === '"') { + this.startString("value", isErrorValue, false); + return; + } + if (character === "t" || character === "f" || character === "n") { + const expected = character === "t" ? "true" : character === "f" ? "false" : "null"; + this.token = { + kind: "literal", + expected, + index: 1, + truthiness: character === "t", + isErrorValue, + }; + return; + } + if (character === "-" || (character >= "0" && character <= "9")) { + this.token = { + kind: "number", + state: character === "-" ? "sign" : character === "0" ? "zero" : "integer", + mantissaNonZero: character >= "1" && character <= "9", + isErrorValue, + }; + return; + } + this.invalidate(); + } + + private startString(role: "key" | "value", isErrorValue: boolean, trackKey: boolean): void { + this.token = { + kind: "string", + role, + isErrorValue, + trackKey, + keyMatches: true, + keyLength: 0, + hasContent: false, + escaping: false, + unicode: undefined, + }; + } + + private consumeToken(token: JsonToken, character: string): void { + if (token.kind === "string") { + this.consumeString(token, character); + return; + } + if (token.kind === "literal") { + this.consumeLiteral(token, character); + return; + } + this.consumeNumber(token, character); + } + + private consumeString(token: Extract, character: string): void { + if (token.unicode !== undefined) { + if (!/^[\da-f]$/iu.test(character)) { + this.invalidate(); + return; + } + token.unicode += character; + if (token.unicode.length === 4) { + this.recordStringCharacter(token, String.fromCodePoint(Number.parseInt(token.unicode, 16))); + token.unicode = undefined; + } + return; + } + if (token.escaping) { + token.escaping = false; + if (character === "u") { + token.unicode = ""; + return; + } + const escaped = { + '"': '"', + "\\": "\\", + "/": "/", + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t", + }[character]; + if (escaped === undefined) { + this.invalidate(); + } else { + this.recordStringCharacter(token, escaped); + } + return; + } + if (character === '"') { + this.token = undefined; + if (token.role === "key") { + const context = this.stack[this.stack.length - 1]; + if ( + !context || + context.kind !== "object" || + (context.state !== "keyOrEnd" && context.state !== "key") + ) { + this.invalidate(); + return; + } + context.keyIsErrors = token.trackKey && token.keyMatches && token.keyLength === 6; + context.state = "colon"; + } else { + this.completeValue(token.hasContent, token.isErrorValue); + } + return; + } + if (character === "\\") { + token.escaping = true; + return; + } + if (character.codePointAt(0)! < 0x20) { + this.invalidate(); + return; + } + this.recordStringCharacter(token, character); + } + + private recordStringCharacter( + token: Extract, + character: string, + ): void { + token.hasContent = true; + if (token.trackKey) { + token.keyMatches = token.keyMatches && "errors"[token.keyLength] === character; + token.keyLength += 1; + } + } + + private consumeLiteral(token: Extract, character: string): void { + if (token.index < token.expected.length) { + if (character !== token.expected[token.index]) { + this.invalidate(); + } else { + token.index += 1; + } + return; + } + if (!this.isValueTerminator(character)) { + this.invalidate(); + return; + } + this.token = undefined; + this.completeValue(token.truthiness, token.isErrorValue); + this.consumeCharacter(character); + } + + private consumeNumber(token: Extract, character: string): void { + if (this.isValueTerminator(character)) { + this.token = undefined; + this.finishNumber(token); + this.consumeCharacter(character); + return; + } + const digit = character >= "0" && character <= "9"; + if (token.state === "sign") { + if (character === "0") { + token.state = "zero"; + } else if (character >= "1" && character <= "9") { + token.state = "integer"; + token.mantissaNonZero = true; + } else { + this.invalidate(); + } + return; + } + if (token.state === "zero") { + if (character === ".") { + token.state = "fractionStart"; + } else if (character === "e" || character === "E") { + token.state = "exponentStart"; + } else { + this.invalidate(); + } + return; + } + if (token.state === "integer") { + if (digit) { + token.mantissaNonZero ||= character !== "0"; + } else if (character === ".") { + token.state = "fractionStart"; + } else if (character === "e" || character === "E") { + token.state = "exponentStart"; + } else { + this.invalidate(); + } + return; + } + if (token.state === "fractionStart") { + if (digit) { + token.state = "fraction"; + token.mantissaNonZero ||= character !== "0"; + } else { + this.invalidate(); + } + return; + } + if (token.state === "fraction") { + if (digit) { + token.mantissaNonZero ||= character !== "0"; + } else if (character === "e" || character === "E") { + token.state = "exponentStart"; + } else { + this.invalidate(); + } + return; + } + if (token.state === "exponentStart") { + if (character === "+" || character === "-") { + token.state = "exponentSign"; + } else if (digit) { + token.state = "exponent"; + } else { + this.invalidate(); + } + return; + } + if (token.state === "exponentSign") { + if (digit) { + token.state = "exponent"; + } else { + this.invalidate(); + } + return; + } + if (!digit) { + this.invalidate(); + } + } + + private closeContainer(context: JsonContainer): void { + const allowed = + context.kind === "object" + ? context.state === "keyOrEnd" || context.state === "commaOrEnd" + : context.state === "valueOrEnd" || context.state === "commaOrEnd"; + if (!allowed) { + this.invalidate(); + return; + } + this.stack.pop(); + this.completeValue(true, context.isErrorValue); + } + + private completeValue(truthiness: boolean, isErrorValue: boolean): void { + if (isErrorValue) { + this.errorsValue = truthiness; + } + const parent = this.stack[this.stack.length - 1]; + if (!parent) { + this.rootState = "complete"; + return; + } + if (parent.kind === "object") { + if (parent.state !== "value") { + this.invalidate(); + return; + } + parent.state = "commaOrEnd"; + parent.keyIsErrors = false; + } else if (parent.state === "valueOrEnd" || parent.state === "value") { + parent.state = "commaOrEnd"; + } else { + this.invalidate(); + } + } + + private finishToken(): void { + const token = this.token; + if (!token) { + return; + } + this.token = undefined; + if (token.kind === "string") { + this.invalidate(); + } else if (token.kind === "literal") { + if (token.index === token.expected.length) { + this.completeValue(token.truthiness, token.isErrorValue); + } else { + this.invalidate(); + } + } else { + this.finishNumber(token); + } + } + + private finishNumber(token: Extract): void { + if ( + token.state !== "zero" && + token.state !== "integer" && + token.state !== "fraction" && + token.state !== "exponent" + ) { + this.invalidate(); + return; + } + this.completeValue(token.mantissaNonZero, token.isErrorValue); + } + + private isWhitespace(character: string): boolean { + return character === " " || character === "\n" || character === "\r" || character === "\t"; + } + + private isValueTerminator(character: string): boolean { + return ( + this.isWhitespace(character) || character === "," || character === "]" || character === "}" + ); + } + + private invalidate(): void { + this.valid = false; + this.token = undefined; + } +} + async function readGraphQlText(response: Response): Promise { return readLimitedText(response, { errorMessage: "GraphQL response exceeded byte limit" }); } diff --git a/packages/core/src/http-actions.ts b/packages/core/src/http-actions.ts index cdb37596..eb191f18 100644 --- a/packages/core/src/http-actions.ts +++ b/packages/core/src/http-actions.ts @@ -10,7 +10,8 @@ import { } from "./downstream"; import { CapletsError, toSafeError } from "./errors"; import { readHttpLikeResponse } from "./http/response"; -import { isAbortError } from "./http/utils"; +import { DEFAULT_MAX_RESPONSE_BYTES, isAbortError } from "./http/utils"; +import { httpLikeMediaOutputSchema } from "./media/results"; import type { ServerRegistry } from "./registry"; import { markdownStructuredContent } from "./result-content"; import { searchToolList } from "./tool-search"; @@ -25,7 +26,7 @@ export class HttpActionManager { authDir?: string; artifactDir?: string; exposeLocalArtifactPaths?: boolean; - maxInlineBytes?: number; + mediaInlineThresholdBytes?: number; } = {}, ) {} @@ -112,7 +113,7 @@ export class HttpActionManager { method: operation.method, ...(this.options.artifactDir ? { artifactDir: this.options.artifactDir } : {}), ...(this.options.exposeLocalArtifactPaths === false ? { exposeLocalPath: false } : {}), - maxInlineBytes: this.options.maxInlineBytes ?? api.maxResponseBytes, + maxInlineBytes: this.options.mediaInlineThresholdBytes ?? DEFAULT_MAX_RESPONSE_BYTES, maxBytes: api.maxResponseBytes, })), elapsedMs: Date.now() - startedAt, @@ -172,7 +173,9 @@ export class HttpActionManager { ...(operation.avoidWhen ? { avoidWhen: operation.avoidWhen } : {}), inputSchema: (operation.inputSchema ?? DEFAULT_INPUT_SCHEMA) as Tool["inputSchema"], ...(operation.outputSchema - ? { outputSchema: operation.outputSchema as Tool["outputSchema"] } + ? { + outputSchema: httpLikeMediaOutputSchema(operation.outputSchema) as Tool["outputSchema"], + } : {}), annotations: { readOnlyHint: operation.method === "GET", diff --git a/packages/core/src/http/response.ts b/packages/core/src/http/response.ts index 6866ca7b..7ba8d833 100644 --- a/packages/core/src/http/response.ts +++ b/packages/core/src/http/response.ts @@ -1,6 +1,7 @@ -import type { MediaArtifact } from "../media"; +import type { MediaArtifact, MediaArtifactWriter } from "../media/artifacts"; +import { createMediaArtifactWriter } from "../media"; +import { mediaResultForArtifact, type HttpLikeMediaResult } from "../media/results"; import { CapletsError } from "../errors"; -import { writeMediaArtifact } from "../media"; import { DEFAULT_MAX_RESPONSE_BYTES, parseHttpBody } from "./utils"; export type ReadHttpLikeResponseOptions = { @@ -13,12 +14,13 @@ export type ReadHttpLikeResponseOptions = { maxBytes?: number; forceArtifact?: boolean; exposeLocalPath?: boolean; + inspectChunk?: (chunk: Uint8Array) => void; }; export async function readHttpLikeResponse( response: Response, options: ReadHttpLikeResponseOptions, -): Promise> { +): Promise { const contentType = response.headers.get("content-type") ?? ""; const mimeType = mimeFromContentType(contentType); const maxInlineBytes = options.maxInlineBytes ?? DEFAULT_MAX_RESPONSE_BYTES; @@ -26,71 +28,104 @@ export async function readHttpLikeResponse( const method = options.method?.toUpperCase(); await rejectOversizedContentLength(response, maxBytes, method); if (method === "HEAD") { - return responseEnvelope(response, contentType); + return inlineResponseEnvelope(response, contentType); } - if (!options.forceArtifact && shouldInline(response, mimeType)) { - const inline = await readInlineCandidate(response, { maxInlineBytes, maxBytes }); - if (!inline.exceeded) { - const body = parseHttpBody(contentType, new TextDecoder().decode(inline.bytes)); - return responseEnvelope(response, contentType, body); - } - const artifact = await writeResponseArtifact(response, options, mimeType, inline.bytes); - return responseEnvelope(response, contentType, { artifact }); - } + return await readResponseBody(response, options, { + contentType, + mimeType, + maxInlineBytes, + maxBytes, + allowInline: !options.forceArtifact && shouldInline(response, mimeType), + }); +} - const bytes = await readBoundedBytes(response, maxBytes); - const artifact = await writeResponseArtifact(response, options, mimeType, bytes); - return responseEnvelope(response, contentType, { artifact }); +function inspectResponseBody(contentType: string, bytes: Buffer): unknown { + return parseHttpBody(contentType, new TextDecoder().decode(bytes)); } -async function readInlineCandidate( +async function readResponseBody( response: Response, - options: { maxInlineBytes: number; maxBytes: number }, -): Promise<{ bytes: Buffer; exceeded: boolean }> { - if (!response.body) { - return { bytes: Buffer.alloc(0), exceeded: false }; - } - const reader = response.body.getReader(); + options: ReadHttpLikeResponseOptions, + settings: { + contentType: string; + mimeType: string; + maxInlineBytes: number; + maxBytes: number; + allowInline: boolean; + }, +): Promise { + const reader = response.body?.getReader(); const chunks: Uint8Array[] = []; - let bytes = 0; - let exceeded = false; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) { - bytes += value.byteLength; - if (bytes > options.maxBytes) { + let byteLength = 0; + let writer: MediaArtifactWriter | undefined; + const inspectChunk = + settings.mimeType === "application/json" || + settings.mimeType.endsWith("+json") || + settings.mimeType.endsWith("/json") + ? options.inspectChunk + : undefined; + try { + if (!settings.allowInline) { + writer = await createResponseArtifactWriter(response, options, settings.mimeType); + } + while (reader) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + byteLength += value.byteLength; + if (byteLength > settings.maxBytes) { await reader.cancel(); - throw responseExceededLimit(options.maxBytes); + throw responseExceededLimit(settings.maxBytes); } - if (bytes > options.maxInlineBytes) exceeded = true; - chunks.push(value); + if (!writer && byteLength <= settings.maxInlineBytes) { + chunks.push(value); + inspectChunk?.(value); + continue; + } + if (!writer) { + writer = await createResponseArtifactWriter(response, options, settings.mimeType); + for (const chunk of chunks) { + await writer.write(chunk); + } + chunks.length = 0; + } + await writer.write(value); + inspectChunk?.(value); + } + if (writer) { + return artifactResponseEnvelope(response, settings.contentType, await writer.complete()); } + return inlineResponseEnvelope( + response, + settings.contentType, + inspectResponseBody(settings.contentType, Buffer.concat(chunks)), + ); + } catch (error) { + await reader?.cancel().catch(() => {}); + await writer?.abort().catch(() => {}); + throw error; } - return { bytes: Buffer.concat(chunks), exceeded }; } -async function readBoundedBytes(response: Response, maxBytes: number): Promise { - if (!response.body) { - return Buffer.alloc(0); - } - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let bytes = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) { - bytes += value.byteLength; - if (bytes > maxBytes) { - await reader.cancel(); - throw responseExceededLimit(maxBytes); - } - chunks.push(value); - } - } - return Buffer.concat(chunks); +async function createResponseArtifactWriter( + response: Response, + options: ReadHttpLikeResponseOptions, + mimeType: string, +): Promise { + return await createMediaArtifactWriter({ + capletId: options.capletId, + ...(options.artifactDir ? { rootDir: options.artifactDir } : {}), + ...(response.ok && options.outputPath ? { outputPath: options.outputPath } : {}), + ...(options.exposeLocalPath === false ? { exposeLocalPath: false } : {}), + suggestedFilename: + options.filename ?? filenameFromContentDisposition(response) ?? "response.bin", + ...(mimeType ? { mimeType } : {}), + }); } async function rejectOversizedContentLength( @@ -115,36 +150,34 @@ function responseExceededLimit(maxBytes: number): CapletsError { ); } -async function writeResponseArtifact( +function inlineResponseEnvelope( response: Response, - options: ReadHttpLikeResponseOptions, - mimeType: string, - bytes: Buffer, -): Promise { - return await writeMediaArtifact({ - capletId: options.capletId, - ...(options.artifactDir ? { rootDir: options.artifactDir } : {}), - ...(response.ok && options.outputPath ? { outputPath: options.outputPath } : {}), - ...(options.exposeLocalPath === false ? { exposeLocalPath: false } : {}), - suggestedFilename: - options.filename ?? filenameFromContentDisposition(response) ?? "response.bin", - ...(mimeType ? { mimeType } : {}), - bytes, - }); + contentType: string, + body?: unknown, +): HttpLikeMediaResult { + return { + status: response.status, + statusText: response.statusText, + headers: { + "content-type": contentType, + }, + kind: "inline", + ...(body === undefined ? {} : { body }), + }; } -function responseEnvelope( +function artifactResponseEnvelope( response: Response, contentType: string, - body?: unknown, -): Record { + artifact: MediaArtifact, +): HttpLikeMediaResult { return { status: response.status, statusText: response.statusText, headers: { "content-type": contentType, }, - ...(body === undefined ? {} : { body }), + ...mediaResultForArtifact(artifact), }; } @@ -199,4 +232,4 @@ function parseQuotedFilename(contentDisposition: string): string | undefined { return /(?:^|;)\s*filename=([^;]+)/iu.exec(contentDisposition)?.[1]?.trim(); } -export type { MediaArtifact }; +export type { HttpLikeMediaResult }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7d6a070f..ff818b61 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,7 +25,7 @@ export type { } from "./runtime-plan"; export { capabilityDescription, ServerRegistry } from "./registry"; export { generatedToolInputSchema, handleServerTool } from "./tools"; -export type { CapletExecutionMetadata, CapletResultMetadata } from "./tools"; +export type { CapletArtifact, CapletExecutionMetadata, CapletResultMetadata } from "./tools"; export { createCodeModeCapletsApi, listCodeModeCallableCaplets } from "./code-mode/api"; export type { CodeModeCapletHandle, diff --git a/packages/core/src/media/artifacts.ts b/packages/core/src/media/artifacts.ts index d59a24dd..4b66f8f1 100644 --- a/packages/core/src/media/artifacts.ts +++ b/packages/core/src/media/artifacts.ts @@ -1,14 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; -import { - chmodSync, - existsSync, - lstatSync, - mkdirSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; +import { existsSync, lstatSync, readFileSync, statSync } from "node:fs"; +import { chmod, mkdir, open, readdir, rename, rm, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; import { DEFAULT_ARTIFACT_DIR } from "../config/paths"; import { CapletsError } from "../errors"; @@ -33,6 +25,14 @@ export type WriteMediaArtifactInput = { exposeLocalPath?: boolean; }; +export type WriteMediaArtifactStreamInput = Omit; + +export type MediaArtifactWriter = { + write(bytes: Uint8Array): Promise; + complete(): Promise; + abort(): Promise; +}; + type StoredMediaArtifactMetadata = { mimeType?: string; }; @@ -43,6 +43,68 @@ type ParsedArtifactUri = { filename: string; }; +const artifactPublicationLocks = new Map>(); + +async function serializeArtifactPublication( + target: string, + operation: () => Promise, +): Promise { + const previous = artifactPublicationLocks.get(target) ?? Promise.resolve(); + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const queued = previous.catch(() => {}).then(() => gate); + artifactPublicationLocks.set(target, queued); + await previous.catch(() => {}); + try { + return await operation(); + } finally { + release?.(); + if (artifactPublicationLocks.get(target) === queued) { + artifactPublicationLocks.delete(target); + } + } +} + +async function removeArtifactBackups(paths: string[], errorMessage: string): Promise { + let pending = paths; + for (let attempt = 0; attempt < 2 && pending.length > 0; attempt += 1) { + const results = await Promise.allSettled( + pending.map(async (path) => await rm(path, { force: true })), + ); + pending = results.flatMap((result, index) => + result.status === "rejected" ? [pending[index]!] : [], + ); + } + if (pending.length > 0) { + throw new CapletsError("DOWNSTREAM_TOOL_ERROR", errorMessage); + } +} + +async function scavengeArtifactBackups(target: string): Promise { + let entries: string[]; + try { + entries = await readdir(dirname(target)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + const prefix = `.${basename(target)}.`; + await removeArtifactBackups( + entries + .filter( + (entry) => + entry.startsWith(prefix) && + (entry.endsWith(".previous") || entry.endsWith(".previous.caplets.json")), + ) + .map((entry) => resolve(dirname(target), entry)), + "Could not remove stale media artifact publication backups", + ); +} + export function artifactUri(capletId: string, callId: string, filename: string): string { return `caplets://artifacts/${encodeURIComponent(capletId)}/${encodeURIComponent( callId, @@ -50,6 +112,20 @@ export function artifactUri(capletId: string, callId: string, filename: string): } export async function writeMediaArtifact(input: WriteMediaArtifactInput): Promise { + const { bytes, ...streamInput } = input; + const writer = await createMediaArtifactWriter(streamInput); + try { + await writer.write(bytes); + return await writer.complete(); + } catch (error) { + await writer.abort(); + throw error; + } +} + +export async function createMediaArtifactWriter( + input: WriteMediaArtifactStreamInput, +): Promise { const rootDir = resolve(input.rootDir ?? DEFAULT_ARTIFACT_DIR); const capletId = requiredSafePathSegment(input.capletId, "capletId"); const callId = safePathSegment(input.callId ?? defaultCallId(), "call"); @@ -60,24 +136,142 @@ export async function writeMediaArtifact(input: WriteMediaArtifactInput): Promis ? assertInsideRoot(rootDir, input.outputPath) : assertInsideRoot(rootDir, resolve(rootDir, capletId, callId, filename)); rejectSymlinkPathComponents(rootDir, target, true); + rejectSymlinkPathComponents(rootDir, artifactMetadataPath(target), true); const uriParts = input.outputPath ? uriPartsForOutputPath(rootDir, target) : { capletId, callId, filename: safeFilename(basename(target)) }; - const bytes = Buffer.from(input.bytes); + const stagingId = randomUUID(); + const targetMetadataPath = artifactMetadataPath(target); + const tempPath = resolve(dirname(target), `.${basename(target)}.${stagingId}.partial`); + const tempMetadataPath = artifactMetadataPath(tempPath); + const backupPath = resolve(dirname(target), `.${basename(target)}.${stagingId}.previous`); + const backupMetadataPath = artifactMetadataPath(backupPath); + + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + rejectSymlinkPathComponents(rootDir, target, true); + rejectSymlinkPathComponents(rootDir, targetMetadataPath, true); + const file = await open(tempPath, "wx", 0o600); + const hash = createHash("sha256"); + let byteLength = 0; + let closed = false; + let completed = false; + let targetBackedUp = false; + let metadataBackedUp = false; + let targetPublished = false; + let metadataPublished = false; + + const closeFile = async (): Promise => { + if (!closed) { + closed = true; + await file.close(); + } + }; + + const removeStaging = async (): Promise => { + await Promise.all([rm(tempPath, { force: true }), rm(tempMetadataPath, { force: true })]); + }; + + const restorePreviousArtifacts = async (): Promise => { + if (metadataPublished) { + await rm(targetMetadataPath, { force: true }); + metadataPublished = false; + } + if (targetPublished) { + await rm(target, { force: true }); + targetPublished = false; + } + if (targetBackedUp) { + await rename(backupPath, target); + targetBackedUp = false; + } + if (metadataBackedUp) { + await rename(backupMetadataPath, targetMetadataPath); + metadataBackedUp = false; + } + await removeStaging(); + }; - mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); - writeFileSync(target, bytes, { mode: 0o600 }); - chmodSync(target, 0o600); + const abort = async (): Promise => { + if (completed) { + return; + } + await closeFile().catch(() => {}); + await removeStaging(); + }; - const artifactFilename = uriParts.filename; - writeArtifactMetadata(target, input.mimeType ? { mimeType: input.mimeType } : {}); return { - uri: artifactUri(uriParts.capletId, uriParts.callId, artifactFilename), - ...(input.exposeLocalPath === false ? {} : { path: target }), - filename: artifactFilename, - ...(input.mimeType ? { mimeType: input.mimeType } : {}), - byteLength: bytes.byteLength, - sha256: sha256(bytes), + async write(bytes: Uint8Array): Promise { + if (completed || closed) { + throw new CapletsError("REQUEST_INVALID", "Media artifact writer is already closed"); + } + const chunk = Buffer.isBuffer(bytes) + ? bytes + : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let offset = 0; + while (offset < chunk.byteLength) { + const { bytesWritten } = await file.write(chunk, offset, chunk.byteLength - offset); + if (bytesWritten === 0) { + throw new CapletsError("DOWNSTREAM_TOOL_ERROR", "Could not write media artifact"); + } + offset += bytesWritten; + } + hash.update(chunk); + byteLength += chunk.byteLength; + }, + async complete(): Promise { + if (completed || closed) { + throw new CapletsError("REQUEST_INVALID", "Media artifact writer is already closed"); + } + await closeFile(); + const digest = hash.digest("hex"); + return await serializeArtifactPublication(target, async () => { + try { + await scavengeArtifactBackups(target); + await chmod(tempPath, 0o600); + await writeArtifactMetadata(tempPath, input.mimeType ? { mimeType: input.mimeType } : {}); + rejectSymlinkPathComponents(rootDir, target, true); + rejectSymlinkPathComponents(rootDir, targetMetadataPath, true); + if (existsSync(target)) { + await rename(target, backupPath); + targetBackedUp = true; + } + if (existsSync(targetMetadataPath)) { + await rename(targetMetadataPath, backupMetadataPath); + metadataBackedUp = true; + } + await rename(tempPath, target); + targetPublished = true; + if (input.mimeType) { + await rename(tempMetadataPath, targetMetadataPath); + metadataPublished = true; + } + completed = true; + } catch (error) { + try { + await restorePreviousArtifacts(); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + "Could not roll back failed media artifact publication", + ); + } + throw error; + } + await removeArtifactBackups( + [backupPath, backupMetadataPath], + "Media artifact was published but backup cleanup failed", + ); + return { + uri: artifactUri(uriParts.capletId, uriParts.callId, uriParts.filename), + ...(input.exposeLocalPath === false ? {} : { path: target }), + filename: uriParts.filename, + ...(input.mimeType ? { mimeType: input.mimeType } : {}), + byteLength, + sha256: digest, + }; + }); + }, + abort, }; } @@ -92,6 +286,7 @@ export function resolveMediaArtifact( resolve(rootDir, parsed.capletId, parsed.callId, parsed.filename), ); rejectSymlinkPathComponents(rootDir, path, true); + rejectSymlinkPathComponents(rootDir, artifactMetadataPath(path), true); if (!existsSync(path)) { throw new CapletsError("REQUEST_INVALID", "Media artifact was not found"); @@ -290,14 +485,17 @@ function artifactMetadataPath(path: string): string { return `${path}.caplets.json`; } -function writeArtifactMetadata(path: string, metadata: StoredMediaArtifactMetadata): void { +async function writeArtifactMetadata( + path: string, + metadata: StoredMediaArtifactMetadata, +): Promise { const metadataPath = artifactMetadataPath(path); if (!metadata.mimeType) { - rmSync(metadataPath, { force: true }); + await rm(metadataPath, { force: true }); return; } - writeFileSync(metadataPath, `${JSON.stringify(metadata)}\n`, { mode: 0o600 }); - chmodSync(metadataPath, 0o600); + await writeFile(metadataPath, `${JSON.stringify(metadata)}\n`, { mode: 0o600 }); + await chmod(metadataPath, 0o600); } function readArtifactMetadata(path: string): StoredMediaArtifactMetadata | undefined { diff --git a/packages/core/src/media/index.ts b/packages/core/src/media/index.ts index 61b743f2..a18c57f7 100644 --- a/packages/core/src/media/index.ts +++ b/packages/core/src/media/index.ts @@ -1,2 +1,3 @@ export * from "./artifacts"; +export * from "./results"; export * from "./input"; diff --git a/packages/core/src/media/input.ts b/packages/core/src/media/input.ts index 8435d3ec..1d0c08f5 100644 --- a/packages/core/src/media/input.ts +++ b/packages/core/src/media/input.ts @@ -3,6 +3,7 @@ import type { Stats } from "node:fs"; import { basename, extname } from "node:path"; import { CapletsError } from "../errors"; import { resolveMediaArtifact } from "./artifacts"; +import { MEDIA_ARTIFACT_MAX_BYTES } from "./results"; export type MediaInput = | { path: string; artifact?: never; dataUrl?: never; filename?: string; mimeType?: string } @@ -15,8 +16,6 @@ export type ResolvedMediaInput = { mimeType?: string; }; -const DEFAULT_MAX_MEDIA_BYTES = 100 * 1024 * 1024; - export async function readMediaInput( input: unknown, options: { artifactRoot?: string; maxBytes?: number; allowLocalPaths?: boolean } = {}, @@ -55,7 +54,7 @@ export async function readMediaInput( if (typeof media.artifact === "string") { const artifactOptions: { artifactRoot?: string; maxBytes?: number } = {}; if (options.artifactRoot !== undefined) artifactOptions.artifactRoot = options.artifactRoot; - artifactOptions.maxBytes = options.maxBytes ?? DEFAULT_MAX_MEDIA_BYTES; + artifactOptions.maxBytes = options.maxBytes ?? MEDIA_ARTIFACT_MAX_BYTES; const artifact = resolveMediaArtifact(media.artifact, artifactOptions); if (!artifact.path) { throw new CapletsError("REQUEST_INVALID", "Media artifact cannot be read from this runtime"); @@ -161,7 +160,7 @@ function readMediaFile(path: string): Buffer { } } -function enforceSize(size: number, maxBytes = DEFAULT_MAX_MEDIA_BYTES): void { +function enforceSize(size: number, maxBytes = MEDIA_ARTIFACT_MAX_BYTES): void { if (size > maxBytes) { throw new CapletsError("REQUEST_INVALID", `media exceeds byte limit ${maxBytes}`); } diff --git a/packages/core/src/media/results.ts b/packages/core/src/media/results.ts new file mode 100644 index 00000000..d96501c6 --- /dev/null +++ b/packages/core/src/media/results.ts @@ -0,0 +1,133 @@ +import type { MediaArtifact } from "./artifacts"; + +export const MEDIA_ARTIFACT_MAX_BYTES = 100 * 1024 * 1024; +const COMMON_MEDIA_PROPERTY: Record = { + status: true, + statusText: true, + headers: true, + kind: true, + elapsedMs: true, +}; +const FORBIDDEN_INLINE_ARTIFACT_PROPERTY: Record = { + uri: false, + path: false, + pathResolution: false, + filename: false, + mimeType: false, + byteLength: false, + sha256: false, +}; + +export type MediaArtifactFacts = { + uri: string; + filename: string; + mimeType?: string; + byteLength: number; + sha256: string; +}; + +export type InlineMediaResult = { + kind: "inline"; + body?: unknown; + uri?: never; + path?: never; + pathResolution?: never; +}; + +export type LocalArtifactMediaResult = MediaArtifactFacts & { + kind: "local-artifact"; + path: string; + pathResolution?: never; +}; + +export type RemoteReferenceMediaResult = MediaArtifactFacts & { + kind: "remote-reference"; + path?: never; + pathResolution?: never; +}; + +export type MediaResult = InlineMediaResult | LocalArtifactMediaResult | RemoteReferenceMediaResult; + +export type HttpLikeMediaResult = { + status: number; + statusText: string; + headers: { "content-type": string }; +} & MediaResult; + +export function mediaResultForArtifact( + artifact: MediaArtifact, +): LocalArtifactMediaResult | RemoteReferenceMediaResult { + const { path, ...facts } = artifact; + return path ? { kind: "local-artifact", path, ...facts } : { kind: "remote-reference", ...facts }; +} + +export function httpLikeMediaOutputSchema( + inlineSchema: Record, +): Record { + const inlineProperties = isRecord(inlineSchema.properties) ? inlineSchema.properties : {}; + const inlineRequired = Array.isArray(inlineSchema.required) + ? inlineSchema.required.filter((value): value is string => typeof value === "string") + : []; + const commonRequired = ["status", "statusText", "headers", "kind"]; + const artifactRequired = [...commonRequired, "uri", "filename", "byteLength", "sha256"]; + const inlineOnlyProperties = Object.fromEntries( + Object.keys(inlineProperties) + .filter((name) => !COMMON_MEDIA_PROPERTY[name]) + .map((name) => [name, false] as const), + ); + + return { + ...inlineSchema, + type: "object", + required: commonRequired, + properties: { + ...inlineProperties, + status: inlineProperties.status ?? { type: "number" }, + statusText: inlineProperties.statusText ?? { type: "string" }, + headers: inlineProperties.headers ?? { + type: "object", + additionalProperties: false, + required: ["content-type"], + properties: { "content-type": { type: "string" } }, + }, + kind: { enum: ["inline", "local-artifact", "remote-reference"] }, + uri: { type: "string" }, + path: { type: "string" }, + filename: { type: "string" }, + mimeType: { type: "string" }, + byteLength: { type: "number" }, + sha256: { type: "string" }, + elapsedMs: { type: "number" }, + }, + oneOf: [ + { + properties: { + kind: { const: "inline" }, + ...FORBIDDEN_INLINE_ARTIFACT_PROPERTY, + }, + required: inlineRequired.includes("kind") ? inlineRequired : [...inlineRequired, "kind"], + }, + { + properties: { + ...inlineOnlyProperties, + kind: { const: "local-artifact" }, + pathResolution: false, + }, + required: [...artifactRequired, "path"], + }, + { + properties: { + ...inlineOnlyProperties, + kind: { const: "remote-reference" }, + path: false, + pathResolution: false, + }, + required: artifactRequired, + }, + ], + }; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/core/src/openapi.ts b/packages/core/src/openapi.ts index cbc22db4..0f539282 100644 --- a/packages/core/src/openapi.ts +++ b/packages/core/src/openapi.ts @@ -13,6 +13,7 @@ import { import { CapletsError, toSafeError } from "./errors"; import { readHttpLikeResponse } from "./http/response"; import { isAbortError, readLimitedText } from "./http/utils"; +import { httpLikeMediaOutputSchema } from "./media/results"; import type { ServerRegistry } from "./registry"; import { markdownStructuredContent } from "./result-content"; import { searchToolList } from "./tool-search"; @@ -66,6 +67,7 @@ export class OpenApiManager { authDir?: string; artifactDir?: string; exposeLocalArtifactPaths?: boolean; + mediaInlineThresholdBytes?: number; } = {}, ) {} @@ -162,6 +164,9 @@ export class OpenApiManager { method: operation.method, ...(this.options.artifactDir ? { artifactDir: this.options.artifactDir } : {}), ...(this.options.exposeLocalArtifactPaths === false ? { exposeLocalPath: false } : {}), + ...(this.options.mediaInlineThresholdBytes === undefined + ? {} + : { maxInlineBytes: this.options.mediaInlineThresholdBytes }), maxBytes: DEFAULT_OPENAPI_RESPONSE_MAX_BYTES, }); return { @@ -493,7 +498,7 @@ function actualSchema(value: unknown): Record | undefined { } function structuredOutputSchema(bodySchema: Record): Record { - return { + return httpLikeMediaOutputSchema({ type: "object", additionalProperties: false, required: ["status", "statusText", "headers"], @@ -511,7 +516,7 @@ function structuredOutputSchema(bodySchema: Record): Record - Boolean( - item && typeof item === "object" && item.type === "text" && typeof item.text === "string", - ), - ) - .map((item) => item.text) - .filter(Boolean) - .join("\n"); -} function renderNamedList(items: unknown[], nameKey: string): string { if (items.length === 0) return "_No items._"; const records = items.map((item) => asRecord(item)); diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 62f2da84..20fda8cd 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -9,6 +9,8 @@ type CapletsRuntimeOptions = { authDir?: string; artifactDir?: string; exposeLocalArtifactPaths?: boolean; + mediaInlineThresholdBytes?: number; + mediaArtifactMaxBytes?: number; watchDebounceMs?: number; server?: ToolServer; writeErr?: (value: string) => void; @@ -79,6 +81,12 @@ function engineOptions(options: CapletsRuntimeOptions): CapletsEngineOptions { if (options.exposeLocalArtifactPaths !== undefined) { engineOptions.exposeLocalArtifactPaths = options.exposeLocalArtifactPaths; } + if (options.mediaInlineThresholdBytes !== undefined) { + engineOptions.mediaInlineThresholdBytes = options.mediaInlineThresholdBytes; + } + if (options.mediaArtifactMaxBytes !== undefined) { + engineOptions.mediaArtifactMaxBytes = options.mediaArtifactMaxBytes; + } if (options.watchDebounceMs !== undefined) { engineOptions.watchDebounceMs = options.watchDebounceMs; } diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index 74f875be..ffdf61a3 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -2196,18 +2196,14 @@ export async function serveHttp( engineOptions: CapletsEngineOptions = {}, writeErr: (value: string) => void = (value) => process.stderr.write(value), ): Promise { - const resolvedEngineOptions = { - exposeLocalArtifactPaths: false, - vaultRecoveryTarget: "remote" as const, - ...engineOptions, - }; - const engine = new CapletsEngine(resolvedEngineOptions); + const remoteEngineOptions = sanitizeRemoteEngineOptions(engineOptions); + const engine = new CapletsEngine(remoteEngineOptions); const app = createHttpServeApp(options, engine, { writeErr, control: { - ...resolvedEngineOptions, - projectCapletsRoot: projectCapletsRootForEngineOptions(resolvedEngineOptions), - globalCapletsRoot: resolveCapletsRoot(resolvedEngineOptions.configPath), + ...remoteEngineOptions, + projectCapletsRoot: projectCapletsRootForEngineOptions(remoteEngineOptions), + globalCapletsRoot: resolveCapletsRoot(remoteEngineOptions.configPath), globalLockfilePath: defaultCapletsLockfilePath(), }, }); @@ -2244,12 +2240,8 @@ export async function serveHttpWithSessionFactory( > = {}, engineOptions: CapletsEngineOptions = {}, ): Promise { - const resolvedEngineOptions = { - exposeLocalArtifactPaths: false, - vaultRecoveryTarget: "remote" as const, - ...engineOptions, - }; - const engine = new CapletsEngine(resolvedEngineOptions); + const remoteEngineOptions = sanitizeRemoteEngineOptions(engineOptions); + const engine = new CapletsEngine(remoteEngineOptions); const app = createHttpServeApp(options, engine, { writeErr, exposeAttach: io.exposeAttach ?? false, @@ -2259,9 +2251,9 @@ export async function serveHttpWithSessionFactory( ? { defaultAttachSessionFactory: io.defaultAttachSessionFactory } : {}), control: { - ...resolvedEngineOptions, - projectCapletsRoot: projectCapletsRootForEngineOptions(resolvedEngineOptions), - globalCapletsRoot: resolveCapletsRoot(resolvedEngineOptions.configPath), + ...remoteEngineOptions, + projectCapletsRoot: projectCapletsRootForEngineOptions(remoteEngineOptions), + globalCapletsRoot: resolveCapletsRoot(remoteEngineOptions.configPath), globalLockfilePath: defaultCapletsLockfilePath(), }, }); @@ -2287,6 +2279,15 @@ export async function serveHttpWithSessionFactory( installHttpSignalHandlers(server, app, engine, writeErr); } +export function sanitizeRemoteEngineOptions( + engineOptions: CapletsEngineOptions, +): CapletsEngineOptions { + return { + ...engineOptions, + exposeLocalArtifactPaths: false, + vaultRecoveryTarget: "remote" as const, + }; +} function projectCapletsRootForEngineOptions(engineOptions: CapletsEngineOptions): string { return engineOptions.projectConfigPath ? resolveProjectCapletsRootForConfigPath(engineOptions.projectConfigPath) diff --git a/packages/core/src/serve/index.ts b/packages/core/src/serve/index.ts index 040d0126..b62cfa74 100644 --- a/packages/core/src/serve/index.ts +++ b/packages/core/src/serve/index.ts @@ -8,7 +8,12 @@ import { import { createNativeCapletsService } from "../native/service"; import type { NativeCapletsService } from "../native/service"; import { isCapletsCloudUrl } from "../remote/options"; -import { CAPLETS_STACK_CHAIN_HEADER, serveHttp, serveHttpWithSessionFactory } from "./http"; +import { + CAPLETS_STACK_CHAIN_HEADER, + sanitizeRemoteEngineOptions, + serveHttp, + serveHttpWithSessionFactory, +} from "./http"; import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "./options"; import { NativeCapletsMcpSession } from "./native-session"; import { serveStdio } from "./stdio"; @@ -54,12 +59,19 @@ async function serveHttpWithUpstream( engineOptions: CapletsEngineOptions, writeErr?: (value: string) => void, ): Promise { + const remoteEngineOptions = sanitizeRemoteEngineOptions(engineOptions); const stackChain = [serveStackIdentity(resolved)]; await serveHttpWithSessionFactory( resolved, async () => new NativeCapletsMcpSession( - await createReloadedUpstreamService(upstreamUrl, engineOptions, writeErr, {}, stackChain), + await createReloadedUpstreamService( + upstreamUrl, + remoteEngineOptions, + writeErr, + {}, + stackChain, + ), ), writeErr, { @@ -68,7 +80,7 @@ async function serveHttpWithUpstream( nativeAttachSession( await createReloadedUpstreamService( upstreamUrl, - engineOptions, + remoteEngineOptions, writeErr, {}, context.stackChain, @@ -78,7 +90,7 @@ async function serveHttpWithUpstream( return nativeAttachSession( await createReloadedUpstreamService( upstreamUrl, - engineOptions, + remoteEngineOptions, writeErr, metadata, context.stackChain, @@ -86,7 +98,7 @@ async function serveHttpWithUpstream( ); }, }, - engineOptions, + remoteEngineOptions, ); } diff --git a/packages/core/src/serve/session.ts b/packages/core/src/serve/session.ts index c202b075..ab6415d4 100644 --- a/packages/core/src/serve/session.ts +++ b/packages/core/src/serve/session.ts @@ -1,3 +1,4 @@ +import Ajv from "ajv"; import { McpServer, ResourceTemplate, @@ -46,6 +47,14 @@ import { } from "../native/tools"; import { capabilityDescription } from "../registry"; +const directToolSchemaAjv = new Ajv({ + allErrors: true, + strict: false, + validateFormats: false, + validateSchema: false, +}); +const directToolSchemaCache = new WeakMap, z.ZodObject>(); + export type ToolServer = Pick & Partial>; @@ -181,14 +190,16 @@ export class CapletsMcpSession { if (entry.kind === "direct-tool") { const directTool = directToolsById.get(entry.id); if (!directTool) continue; + const inputSchema = zodSchemaForDirectTool(directTool.tool.inputSchema); + const outputSchema = zodSchemaForDirectTool(directTool.tool.outputSchema); desiredTools.set(entry.id, { register: () => this.registerDirectTool(directTool), update: (tool) => (tool.update as (updates: Record) => void)({ title: directTool.tool.name, description: directTool.tool.description, - paramsSchema: directTool.tool.inputSchema as never, - outputSchema: directTool.tool.outputSchema as never, + paramsSchema: inputSchema, + outputSchema, annotations: directTool.tool.annotations, _meta: { caplets: { @@ -324,13 +335,15 @@ export class CapletsMcpSession { } private registerDirectTool(entry: DirectToolRegistration): RegisteredTool { + const inputSchema = zodSchemaForDirectTool(entry.tool.inputSchema); + const outputSchema = zodSchemaForDirectTool(entry.tool.outputSchema); return (this.server.registerTool as (...args: unknown[]) => RegisteredTool)( entry.name, { title: entry.tool.name, ...(entry.tool.description ? { description: entry.tool.description } : {}), - ...(entry.tool.inputSchema ? { inputSchema: entry.tool.inputSchema as never } : {}), - ...(entry.tool.outputSchema ? { outputSchema: entry.tool.outputSchema as never } : {}), + ...(inputSchema ? { inputSchema } : {}), + ...(outputSchema ? { outputSchema } : {}), ...(entry.tool.annotations ? { annotations: entry.tool.annotations } : {}), _meta: { caplets: { @@ -413,6 +426,39 @@ export class CapletsMcpSession { } } +function zodSchemaForDirectTool(schema: unknown): z.ZodObject | undefined { + if (!isRecord(schema)) return undefined; + const cached = directToolSchemaCache.get(schema); + if (cached) return cached; + + const properties = isRecord(schema.properties) ? schema.properties : {}; + const required = new Set( + Array.isArray(schema.required) + ? schema.required.filter((value): value is string => typeof value === "string") + : [], + ); + const shape: Record = {}; + for (const [name, propertySchema] of Object.entries(properties)) { + const jsonPropertySchema = propertySchema as Parameters[0]; + const property = z.fromJSONSchema(jsonPropertySchema, { defaultTarget: "draft-7" }); + shape[name] = required.has(name) ? property : property.optional(); + } + + const base = schema.additionalProperties === false ? z.strictObject(shape) : z.looseObject(shape); + const validate = directToolSchemaAjv.compile(schema); + const converted = base.superRefine((value, context) => { + if (validate(value)) return; + for (const error of validate.errors ?? []) { + context.addIssue({ + code: "custom", + message: `${error.instancePath || "value"} ${error.message ?? "is invalid"}`, + }); + } + }); + directToolSchemaCache.set(schema, converted); + return converted; +} + function codeModeRunToolDescription(caplets: CallableCaplet[]): string { const declaration = generateCodeModeDeclarations({ caplets: caplets.map((entry) => ({ diff --git a/packages/core/src/tools.ts b/packages/core/src/tools.ts index 4a769f53..30f3e6ab 100644 --- a/packages/core/src/tools.ts +++ b/packages/core/src/tools.ts @@ -955,11 +955,21 @@ type RequiredOperationRequest = argument: { name: string; value: string }; }; -export type CapletArtifact = { - kind: "screenshot" | "snapshot" | "console-log" | "network-log" | "file"; - displayPath: string; - pathResolution: "absolute" | "relative-to-mcp-server"; -}; +export type CapletArtifact = + | { + kind: "screenshot" | "snapshot" | "console-log" | "network-log" | "file"; + presentation: "local-path"; + displayPath: string; + pathResolution: "absolute" | "relative-to-mcp-server"; + reference?: never; + } + | { + kind: "screenshot" | "snapshot" | "console-log" | "network-log" | "file"; + presentation: "reference"; + reference: string; + displayPath?: never; + pathResolution?: never; + }; export type CapletExecutionMetadata = { kind: "local" | "remote" | "cloud" | "local-fallback"; @@ -1054,20 +1064,24 @@ export function annotateCallToolResult( result: T, metadata: CapletResultMetadata, ): T & CallToolResult { - const existingMeta = (result as { _meta?: unknown })._meta; + const existingMeta = "_meta" in result ? result._meta : undefined; + const existingContent = + "content" in result && Array.isArray(result.content) ? result.content : undefined; + const resultIsError = "isError" in result && result.isError === true; + const callToolResult = result as CallToolResult; const artifacts = extractArtifacts(result); const annotatedMetadata = { ...metadata, - status: (result as { isError?: unknown }).isError === true ? "error" : metadata.status, + status: resultIsError ? "error" : metadata.status, ...(artifacts.length === 0 ? {} : { artifacts }), }; return { ...result, - content: markdownCallToolResultContent( - result as CallToolResult, - markdownContextFor(annotatedMetadata), - ), + content: + existingContent === undefined + ? markdownCallToolResultContent(callToolResult, markdownContextFor(annotatedMetadata)) + : [...existingContent], _meta: { ...(isPlainObject(existingMeta) ? existingMeta : {}), caplets: annotatedMetadata, @@ -1111,22 +1125,14 @@ function hasArtifactPlaceholderForSelectedFields( structuredContent: Record, fields: string[], ): boolean { - const body = structuredContent.body; return ( - isPlainObject(body) && - isPlainObject(body.artifact) && - isArtifactPlaceholder(body.artifact) && + isArtifactMediaResult(structuredContent) && fields.some((field) => field === "body" || field.startsWith("body.")) ); } -function isArtifactPlaceholder(value: Record): boolean { - return ( - typeof value.uri === "string" && - value.uri.startsWith("caplets://artifacts/") && - typeof value.byteLength === "number" && - typeof value.sha256 === "string" - ); +function isArtifactMediaResult(value: Record): boolean { + return value.kind === "local-artifact" || value.kind === "remote-reference"; } export function extractArtifacts(result: unknown): CapletArtifact[] { @@ -1173,6 +1179,7 @@ export function extractArtifacts(result: unknown): CapletArtifact[] { seen.add(displayPath); artifacts.push({ kind: artifactKind(displayPath, label, surroundingText), + presentation: "local-path", displayPath, pathResolution: isAbsoluteLocalPath(displayPath) ? "absolute" : "relative-to-mcp-server", }); @@ -1186,18 +1193,28 @@ function addStructuredArtifact( seen: Set, structuredContent: unknown, ): void { - if (!isPlainObject(structuredContent)) return; - const body = structuredContent.body; - if (!isPlainObject(body) || !isPlainObject(body.artifact)) return; - const path = typeof body.artifact.path === "string" ? body.artifact.path : undefined; - const uri = typeof body.artifact.uri === "string" ? body.artifact.uri : undefined; - const displayPath = path ?? uri; - if (!displayPath || seen.has(displayPath)) return; - seen.add(displayPath); + if (!isPlainObject(structuredContent) || !isArtifactMediaResult(structuredContent)) return; + + if (structuredContent.kind === "local-artifact") { + const path = typeof structuredContent.path === "string" ? structuredContent.path : undefined; + if (!path || seen.has(path)) return; + seen.add(path); + artifacts.push({ + kind: "file", + presentation: "local-path", + displayPath: path, + pathResolution: "absolute", + }); + return; + } + + const reference = typeof structuredContent.uri === "string" ? structuredContent.uri : undefined; + if (!reference || seen.has(reference)) return; + seen.add(reference); artifacts.push({ kind: "file", - displayPath, - pathResolution: path ? "absolute" : "relative-to-mcp-server", + presentation: "reference", + reference, }); } diff --git a/packages/core/test/attach-api.test.ts b/packages/core/test/attach-api.test.ts index d48e74d1..e9276d5d 100644 --- a/packages/core/test/attach-api.test.ts +++ b/packages/core/test/attach-api.test.ts @@ -1,3 +1,7 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { buildAttachProjection, @@ -6,8 +10,10 @@ import { invokeNativeAttachExport, type AttachProjection, } from "../src/attach/api"; -import type { CapletsEngine } from "../src/engine"; +import { parseConfig } from "../src/config"; +import { CapletsEngine } from "../src/engine"; import type { NativeCapletsService } from "../src/native/service"; +import { sanitizeRemoteEngineOptions } from "../src/serve/http"; describe("Attach API dispatch", () => { it("sorts attach exports before hashing revisions", async () => { @@ -445,6 +451,68 @@ describe("Attach API dispatch", () => { ]); }); + it("returns reference-only HTTP artifacts from Attach exports", async () => { + const http = await startPdfServer(); + try { + const artifactDir = mkdtempSync(join(tmpdir(), "caplets-attach-artifacts-")); + const engine = new CapletsEngine( + sanitizeRemoteEngineOptions({ + artifactDir, + exposeLocalArtifactPaths: true, + watch: false, + configLoader: () => + parseConfig({ + options: { exposure: "direct" }, + httpApis: { + status: { + name: "Status HTTP", + description: "Download an Attach report.", + exposure: "direct", + baseUrl: http.baseUrl, + auth: { type: "none" }, + actions: { download: { method: "GET", path: "/report" } }, + }, + }, + }), + }), + ); + + try { + const projection = await buildAttachProjection(engine); + const tool = projection.manifest.tools.find((entry) => entry.name === "status__download"); + if (!tool) throw new Error("expected the HTTP Attach export"); + const result = await invokeAttachExport(engine, projection, { + revision: projection.manifest.revision, + kind: "tool", + exportId: tool.exportId, + input: {}, + }); + const structuredContent = remoteArtifact(result); + const reference = artifactReference(result); + + expect(structuredContent).toMatchObject({ + kind: "remote-reference", + uri: expect.stringMatching(/^caplets:\/\/artifacts\//u), + mimeType: "application/pdf", + byteLength: 15, + }); + expect(structuredContent).not.toHaveProperty("path"); + expect(structuredContent).not.toHaveProperty("pathResolution"); + expect(reference).toMatchObject({ + presentation: "reference", + reference: structuredContent.uri, + }); + expect(reference).not.toHaveProperty("path"); + expect(reference).not.toHaveProperty("pathResolution"); + } finally { + await engine.close(); + rmSync(artifactDir, { recursive: true, force: true }); + } + } finally { + await http.close(); + } + }); + it("reads resource template exports from an explicit expanded URI", async () => { const engine = { execute: vi.fn(async () => ({ ok: true })), @@ -861,3 +929,44 @@ describe("Attach API dispatch", () => { }); }); }); + +async function startPdfServer(): Promise<{ baseUrl: string; close: () => Promise }> { + const server = createServer((_request, response) => { + response.setHeader("content-type", "application/pdf"); + response.end(Buffer.from("%PDF-1.7 attach")); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolve) => server.close(() => resolve())); + throw new Error("Attach HTTP test server did not bind"); + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function remoteArtifact(result: unknown): Record { + if (isRecord(result) && isRecord(result.structuredContent)) { + return result.structuredContent; + } + throw new Error("expected structured artifact content"); +} + +function artifactReference(result: unknown): Record { + if (!isRecord(result) || !isRecord(result._meta) || !isRecord(result._meta.caplets)) { + throw new Error("expected Caplets result metadata"); + } + const artifacts = result._meta.caplets.artifacts; + if (Array.isArray(artifacts) && isRecord(artifacts[0])) { + return artifacts[0]; + } + throw new Error("expected artifact reference metadata"); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/core/test/caplet-sets.test.ts b/packages/core/test/caplet-sets.test.ts index 7c4dead0..73025eb9 100644 --- a/packages/core/test/caplet-sets.test.ts +++ b/packages/core/test/caplet-sets.test.ts @@ -1,11 +1,15 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { CapletSetManager } from "../src/caplet-sets"; import { parseConfig } from "../src/config"; +import { DownstreamManager } from "../src/downstream"; import { ServerRegistry } from "../src/registry"; import { FileVaultStore } from "../src/vault"; +import { handleServerTool } from "../src/tools"; describe("CapletSetManager", () => { const dirs: string[] = []; @@ -68,6 +72,75 @@ describe("CapletSetManager", () => { }); }); + it("preserves nested MCP mixed content through the outer Caplet-set annotation", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-set-mixed-mcp-")); + dirs.push(dir); + const childConfigPath = join(dir, "child.json"); + const fixture = fileURLToPath( + new URL("fixtures/stdio-mixed-content-server.ts", import.meta.url), + ); + writeFileSync( + childConfigPath, + JSON.stringify({ + mcpServers: { + mixed: { + name: "Mixed MCP", + description: "Return ordered mixed content blocks.", + command: process.execPath, + args: ["--import", import.meta.resolve("tsx"), fixture], + }, + }, + }), + ); + const config = parseConfig({ + capletSets: { + nested: { + name: "Nested Caplets", + description: "Expose child Caplets through a nested collection.", + configPath: childConfigPath, + }, + }, + }); + const caplet = config.capletSets.nested!; + const registry = new ServerRegistry(config); + const downstream = new DownstreamManager(registry); + const manager = new CapletSetManager(registry); + + try { + const result = await handleServerTool( + caplet, + { + operation: "call_tool", + name: "mixed", + args: { operation: "call_tool", name: "mixed", args: {} }, + }, + registry, + downstream, + undefined, + undefined, + undefined, + undefined, + manager, + ); + + expect(result.content).toEqual([ + { type: "text", text: "Downstream text" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + { + type: "resource_link", + uri: "file:///tmp/report.pdf", + name: "Report", + mimeType: "application/pdf", + }, + ]); + expect(result.structuredContent).toEqual({ snapshot: { title: "Example" } }); + expect(result.isError).toBe(true); + } finally { + await manager.close(); + await downstream.close(); + } + }); + it("loads child config and child Caplet files from independently configured sources", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-set-files-")); dirs.push(dir); @@ -219,6 +292,74 @@ describe("CapletSetManager", () => { expect(child?.googleDiscovery.options.artifactDir).toBe(artifactDir); }); + it("propagates Media thresholds, artifact roots, and hidden paths to nested HTTP Actions", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-set-http-media-")); + dirs.push(dir); + const server = createServer((_request, response) => { + response.setHeader("content-type", "text/plain"); + response.end("x".repeat(256)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("nested HTTP test server did not bind"); + } + + const artifactDir = join(dir, "artifacts"); + const childConfigPath = join(dir, "child.json"); + writeFileSync( + childConfigPath, + JSON.stringify({ + httpApis: { + child: { + name: "Child HTTP", + description: "Nested HTTP response.", + baseUrl: `http://127.0.0.1:${address.port}`, + auth: { type: "none" }, + maxResponseBytes: 512, + actions: { read: { method: "GET", path: "/response" } }, + }, + }, + }), + ); + const config = parseConfig({ + capletSets: { + nested: { + name: "Nested Caplets", + description: "Expose child Caplets through a nested collection.", + configPath: childConfigPath, + }, + }, + }); + const caplet = config.capletSets.nested!; + const manager = new CapletSetManager(new ServerRegistry(config), { + artifactDir, + exposeLocalArtifactPaths: false, + mediaInlineThresholdBytes: 128, + mediaArtifactMaxBytes: 1024, + }); + + try { + const result = await manager.callTool(caplet, "child", { + operation: "call_tool", + name: "read", + args: {}, + }); + + expect(result.structuredContent).toMatchObject({ + kind: "remote-reference", + uri: expect.stringMatching(/^caplets:\/\/artifacts\//u), + byteLength: 256, + }); + expect(existsSync(join(artifactDir, "child"))).toBe(true); + expect(result.structuredContent).not.toHaveProperty("path"); + expect(result.structuredContent).not.toHaveProperty("pathResolution"); + } finally { + await manager.close(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("serializes concurrent refreshes for one parent Caplet set", async () => { const { dir, childConfigPath } = childCliConfig(); dirs.push(dir); diff --git a/packages/core/test/fixtures/stdio-mixed-content-server.ts b/packages/core/test/fixtures/stdio-mixed-content-server.ts new file mode 100644 index 00000000..4eb92720 --- /dev/null +++ b/packages/core/test/fixtures/stdio-mixed-content-server.ts @@ -0,0 +1,30 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio"; +import { z } from "zod"; + +const server = new McpServer({ name: "fixture-stdio-mixed-content", version: "1.0.0" }); + +server.registerTool( + "mixed", + { + description: "Return ordered mixed content blocks.", + inputSchema: z.object({}).strict(), + outputSchema: z.object({ snapshot: z.object({ title: z.string() }) }).strict(), + }, + async () => ({ + content: [ + { type: "text" as const, text: "Downstream text" }, + { type: "image" as const, data: "aGVsbG8=", mimeType: "image/png" }, + { + type: "resource_link" as const, + uri: "file:///tmp/report.pdf", + name: "Report", + mimeType: "application/pdf", + }, + ], + structuredContent: { snapshot: { title: "Example" } }, + isError: true, + }), +); + +await server.connect(new StdioServerTransport()); diff --git a/packages/core/test/google-discovery.test.ts b/packages/core/test/google-discovery.test.ts index 829a9e22..f8e49a88 100644 --- a/packages/core/test/google-discovery.test.ts +++ b/packages/core/test/google-discovery.test.ts @@ -1218,7 +1218,9 @@ describe("GoogleDiscoveryManager", () => { expect(result.structuredContent).toMatchObject({ status: 200, - body: { artifact: { filename: "report.pdf", mimeType: "application/pdf" } }, + kind: "local-artifact", + filename: "report.pdf", + mimeType: "application/pdf", }); }); @@ -1250,7 +1252,10 @@ describe("GoogleDiscoveryManager", () => { expect(existsSync(outputPath)).toBe(true); expect(result.structuredContent).toMatchObject({ status: 200, - body: { artifact: { path: outputPath, filename: "export.txt", mimeType: "text/plain" } }, + kind: "local-artifact", + path: outputPath, + filename: "export.txt", + mimeType: "text/plain", }); } finally { rmSync(dir, { recursive: true, force: true }); @@ -1276,7 +1281,9 @@ describe("GoogleDiscoveryManager", () => { expect(result.structuredContent).toMatchObject({ status: 200, - body: { artifact: { filename: "export.pdf", mimeType: "application/pdf" } }, + kind: "local-artifact", + filename: "export.pdf", + mimeType: "application/pdf", }); expect(requests.find((request) => request.url === "/drive/v3/files/1?alt=media")).toBeDefined(); }); @@ -1305,13 +1312,10 @@ describe("GoogleDiscoveryManager", () => { expect(result.structuredContent).toMatchObject({ status: 200, - body: { - artifact: { - filename: "large.pdf", - mimeType: "application/pdf", - byteLength: 1024 * 1024 + 1, - }, - }, + kind: "local-artifact", + filename: "large.pdf", + mimeType: "application/pdf", + byteLength: 1024 * 1024 + 1, }); }); @@ -1335,17 +1339,11 @@ describe("GoogleDiscoveryManager", () => { expect(result.structuredContent).toMatchObject({ status: 200, - body: { - artifact: { - mimeType: "application/json", - byteLength: expect.any(Number), - }, - }, + kind: "local-artifact", + mimeType: "application/json", + byteLength: expect.any(Number), }); - expect( - (result.structuredContent as { body: { artifact: { byteLength: number } } }).body.artifact - .byteLength, - ).toBeGreaterThan(1024 * 1024); + expect(artifactByteLength(result)).toBeGreaterThan(1024 * 1024); }); it("uploads media from dataUrl using multipart when metadata body is present", async () => { @@ -1670,3 +1668,21 @@ describe("GoogleDiscoveryManager", () => { } }); }); + +function artifactByteLength(result: unknown): number { + if ( + result && + typeof result === "object" && + "structuredContent" in result && + result.structuredContent && + typeof result.structuredContent === "object" && + "kind" in result.structuredContent && + (result.structuredContent.kind === "local-artifact" || + result.structuredContent.kind === "remote-reference") && + "byteLength" in result.structuredContent && + typeof result.structuredContent.byteLength === "number" + ) { + return result.structuredContent.byteLength; + } + throw new Error("expected an artifact result"); +} diff --git a/packages/core/test/graphql.test.ts b/packages/core/test/graphql.test.ts index 46844d5c..3eeece79 100644 --- a/packages/core/test/graphql.test.ts +++ b/packages/core/test/graphql.test.ts @@ -1,10 +1,58 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { createHash } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + watch, + writeFileSync, + type FSWatcher, + type PathLike, +} from "node:fs"; +import type * as FsPromises from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GraphQLManager, type GraphqlEndpointConfig } from "../src/graphql"; +import { MEDIA_ARTIFACT_MAX_BYTES } from "../src/media/results"; import { ServerRegistry } from "../src/registry"; +import { + createMediaArtifactWriter, + resolveMediaArtifact, + writeMediaArtifact, +} from "../src/media/artifacts"; + +const mediaArtifactFailure = vi.hoisted(() => ({ + metadataPublication: false, + backupCleanup: false, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const fsPromises = (await importOriginal()) as typeof FsPromises; + return { + ...fsPromises, + rename: async (from: PathLike, to: PathLike): Promise => { + if ( + mediaArtifactFailure.metadataPublication && + String(from).endsWith(".partial.caplets.json") && + String(to).endsWith("response.bin.caplets.json") + ) { + throw new Error("metadata publication failed"); + } + await fsPromises.rename(from, to); + }, + rm: async (...args: Parameters): Promise => { + if ( + mediaArtifactFailure.backupCleanup && + String(args[0]).endsWith(".previous.caplets.json") + ) { + throw new Error("backup cleanup failed"); + } + await fsPromises.rm(...args); + }, + }; +}); describe("GraphQLManager", () => { let baseUrl = ""; @@ -53,6 +101,33 @@ describe("GraphQLManager", () => { operationName?: string; }; response.setHeader("content-type", "application/json"); + if (payload.variables?.id === "large-error") { + response.end( + JSON.stringify({ + errors: [{ message: "response too large" }], + padding: "x".repeat(1024 * 1024), + }), + ); + return; + } + if (payload.variables?.id === "exact-inline") { + response.end(graphQlPayload(1024 * 1024)); + return; + } + if (payload.variables?.id === "over-inline") { + response.end(graphQlPayload(1024 * 1024 + 1)); + return; + } + if (payload.variables?.id === "advertised-over-cap") { + response.setHeader("content-length", String(MEDIA_ARTIFACT_MAX_BYTES + 1)); + response.end("x"); + return; + } + if (payload.variables?.id === "streamed-over-cap") { + response.write("x".repeat(9)); + response.end("x".repeat(8)); + return; + } if (payload.variables?.id === "missing") { response.end(JSON.stringify({ errors: [{ message: "not found" }] })); return; @@ -198,6 +273,332 @@ describe("GraphQLManager", () => { }); }); + it("artifactizes oversized GraphQL errors without losing error classification", async () => { + const artifactDir = mkdtempSync(join(tmpdir(), "caplets-graphql-artifacts-")); + const manager = new GraphQLManager(registry(), { + artifactDir, + exposeLocalArtifactPaths: false, + }); + const endpoint = graphqlEndpoint({ + schemaUrl: `${baseUrl}/schema.graphql`, + endpointUrl: `${baseUrl}/graphql`, + }); + + try { + const result = await manager.callTool(endpoint, "query_user", { id: "large-error" }); + + expect(result).toMatchObject({ + isError: true, + structuredContent: { + kind: "remote-reference", + uri: expect.stringMatching(/^caplets:\/\/artifacts\//u), + filename: "response.bin", + byteLength: expect.any(Number), + sha256: expect.any(String), + }, + }); + expect(result.structuredContent).not.toHaveProperty("path"); + expect(result.structuredContent).not.toHaveProperty("pathResolution"); + } finally { + rmSync(artifactDir, { recursive: true, force: true }); + } + }); + + it("streams oversized GraphQL artifacts before the upstream response completes", async () => { + const artifactDir = mkdtempSync(join(tmpdir(), "caplets-graphql-streaming-artifacts-")); + let releaseResponse: (() => void) | undefined; + let artifactWatcher: FSWatcher | undefined; + const partialArtifactCreated = new Promise((resolve) => { + artifactWatcher = watch(artifactDir, { recursive: true }, (_event, filename) => { + if (String(filename).endsWith(".partial")) { + artifactWatcher?.close(); + resolve(); + } + }); + }); + let markFirstChunkSent: (() => void) | undefined; + const firstChunkSent = new Promise((resolve) => { + markFirstChunkSent = resolve; + }); + const artifactServer = createServer(async (_request, response) => { + response.setHeader("content-type", "application/json"); + response.write('{"data":{"user":{"id":"42"}},"padding":"'); + response.write("x".repeat(4096)); + markFirstChunkSent?.(); + await new Promise((resolve) => { + releaseResponse = resolve; + }); + response.end('"}'); + }); + + try { + await new Promise((resolve) => artifactServer.listen(0, "127.0.0.1", resolve)); + const address = artifactServer.address(); + if (!address || typeof address === "string") { + throw new Error("streaming GraphQL test server did not bind"); + } + const manager = new GraphQLManager(registry(), { + artifactDir, + mediaInlineThresholdBytes: 1024, + }); + const endpoint = graphqlEndpoint({ + schemaUrl: `${baseUrl}/schema.graphql`, + endpointUrl: `http://127.0.0.1:${address.port}/graphql`, + requestTimeoutMs: 1000, + }); + const resultPromise = manager.callTool(endpoint, "query_user", { id: "streaming" }); + + await firstChunkSent; + const observedBeforeUpstreamCompleted = await Promise.race([ + partialArtifactCreated.then(() => "artifact"), + resultPromise.then( + () => "result", + () => "failed", + ), + ]); + expect(observedBeforeUpstreamCompleted).toBe("artifact"); + releaseResponse?.(); + + await expect(resultPromise).resolves.toMatchObject({ + isError: false, + structuredContent: { + kind: "local-artifact", + byteLength: expect.any(Number), + sha256: expect.any(String), + }, + }); + } finally { + artifactWatcher?.close(); + releaseResponse?.(); + await new Promise((resolve) => artifactServer.close(() => resolve())); + rmSync(artifactDir, { recursive: true, force: true }); + } + }); + + it("removes partial GraphQL artifacts when the streamed hard cap is exceeded", async () => { + const artifactDir = mkdtempSync(join(tmpdir(), "caplets-graphql-streaming-cap-")); + const manager = new GraphQLManager(registry(), { + artifactDir, + mediaInlineThresholdBytes: 8, + mediaArtifactMaxBytes: 16, + }); + const endpoint = graphqlEndpoint({ + schemaUrl: `${baseUrl}/schema.graphql`, + endpointUrl: `${baseUrl}/graphql`, + }); + + try { + await expect( + manager.callTool(endpoint, "query_user", { id: "streamed-over-cap" }), + ).rejects.toMatchObject({ code: "DOWNSTREAM_PROTOCOL_ERROR" }); + const artifactFiles = readdirSync(artifactDir, { + recursive: true, + withFileTypes: true, + }).filter((entry) => entry.isFile()); + expect(artifactFiles).toEqual([]); + } finally { + rmSync(artifactDir, { recursive: true, force: true }); + } + }); + + it("restores an existing GraphQL artifact when metadata publication fails", async () => { + const artifactDir = mkdtempSync(join(tmpdir(), "caplets-graphql-transaction-")); + const outputPath = join(artifactDir, "users", "call-1", "response.bin"); + const original = await writeMediaArtifact({ + rootDir: artifactDir, + capletId: "users", + callId: "call-1", + outputPath, + mimeType: "application/json", + bytes: Buffer.from("original GraphQL artifact"), + }); + const originalBytes = readFileSync(outputPath); + const originalMetadata = readFileSync(`${outputPath}.caplets.json`); + mediaArtifactFailure.metadataPublication = true; + + try { + await expect( + writeMediaArtifact({ + rootDir: artifactDir, + capletId: "users", + callId: "call-1", + outputPath, + mimeType: "application/json", + bytes: Buffer.from("replacement GraphQL artifact"), + }), + ).rejects.toThrow("metadata publication failed"); + expect(readFileSync(outputPath)).toEqual(originalBytes); + expect(readFileSync(`${outputPath}.caplets.json`)).toEqual(originalMetadata); + expect( + readdirSync(artifactDir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(), + ).toEqual(["response.bin", "response.bin.caplets.json"]); + expect(original.path).toBe(outputPath); + } finally { + mediaArtifactFailure.metadataPublication = false; + rmSync(artifactDir, { recursive: true, force: true }); + } + }); + + it("keeps a published GraphQL artifact when backup cleanup fails", async () => { + const artifactDir = mkdtempSync(join(tmpdir(), "caplets-graphql-cleanup-")); + const outputPath = join(artifactDir, "users", "call-1", "response.bin"); + await writeMediaArtifact({ + rootDir: artifactDir, + capletId: "users", + callId: "call-1", + outputPath, + mimeType: "application/json", + bytes: Buffer.from("original GraphQL artifact"), + }); + mediaArtifactFailure.backupCleanup = true; + + try { + await expect( + writeMediaArtifact({ + rootDir: artifactDir, + capletId: "users", + callId: "call-1", + outputPath, + mimeType: "application/json", + bytes: Buffer.from("replacement GraphQL artifact"), + }), + ).rejects.toMatchObject({ code: "DOWNSTREAM_TOOL_ERROR" }); + expect(readFileSync(outputPath)).toEqual(Buffer.from("replacement GraphQL artifact")); + expect(readFileSync(`${outputPath}.caplets.json`, "utf8")).toContain("application/json"); + mediaArtifactFailure.backupCleanup = false; + await writeMediaArtifact({ + rootDir: artifactDir, + capletId: "users", + callId: "call-1", + outputPath, + mimeType: "text/plain", + bytes: Buffer.from("cleanup retry GraphQL artifact"), + }); + expect( + readdirSync(artifactDir, { recursive: true, withFileTypes: true }).filter((entry) => + entry.name.includes(".previous"), + ), + ).toEqual([]); + } finally { + mediaArtifactFailure.backupCleanup = false; + rmSync(artifactDir, { recursive: true, force: true }); + } + }); + + it("serializes concurrent GraphQL artifact publication for one output path", async () => { + const artifactDir = mkdtempSync(join(tmpdir(), "caplets-graphql-concurrent-")); + const outputPath = join(artifactDir, "users", "call-1", "response.bin"); + const firstWriter = await createMediaArtifactWriter({ + rootDir: artifactDir, + capletId: "users", + callId: "call-1", + outputPath, + mimeType: "text/plain", + }); + const secondWriter = await createMediaArtifactWriter({ + rootDir: artifactDir, + capletId: "users", + callId: "call-1", + outputPath, + mimeType: "application/json", + }); + + try { + await Promise.all([ + firstWriter.write(Buffer.from("first GraphQL artifact")), + secondWriter.write(Buffer.from('{"artifact":"second"}')), + ]); + const [first, second] = await Promise.all([firstWriter.complete(), secondWriter.complete()]); + const stored = resolveMediaArtifact(first.uri, { artifactRoot: artifactDir }); + const published = [first, second].find( + (candidate) => + candidate.byteLength === stored.byteLength && + candidate.sha256 === stored.sha256 && + candidate.mimeType === stored.mimeType, + ); + + expect(published).toBeDefined(); + expect(stored.sha256).toBe( + createHash("sha256").update(readFileSync(outputPath)).digest("hex"), + ); + } finally { + rmSync(artifactDir, { recursive: true, force: true }); + } + }); + + it("does not classify errors from invalid JSON split across GraphQL response chunks", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-graphql-invalid-json-")); + const schemaPath = join(dir, "schema.graphql"); + writeFileSync(schemaPath, schemaSdl); + vi.stubGlobal( + "fetch", + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"errors":[{"message":"bad"}],')); + controller.enqueue(new TextEncoder().encode("}")); + controller.close(); + }, + }), + { headers: { "content-type": "application/json" } }, + ), + ); + const manager = new GraphQLManager(registry()); + const endpoint = graphqlEndpoint({ schemaPath }); + + try { + const result = await manager.callTool(endpoint, "query_user", { id: "invalid-json" }); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + kind: "inline", + body: '{"errors":[{"message":"bad"}],}', + }); + } finally { + vi.unstubAllGlobals(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("uses the 1 MiB GraphQL inline threshold and bounded artifact cap", async () => { + const artifactDir = mkdtempSync(join(tmpdir(), "caplets-graphql-boundaries-")); + const endpoint = graphqlEndpoint({ + schemaUrl: `${baseUrl}/schema.graphql`, + endpointUrl: `${baseUrl}/graphql`, + }); + const manager = new GraphQLManager(registry(), { artifactDir }); + const boundedManager = new GraphQLManager(registry(), { mediaArtifactMaxBytes: 16 }); + const overCapOverrideManager = new GraphQLManager(registry(), { + mediaArtifactMaxBytes: MEDIA_ARTIFACT_MAX_BYTES * 2, + }); + + try { + const exact = await manager.callTool(endpoint, "query_user", { id: "exact-inline" }); + const over = await manager.callTool(endpoint, "query_user", { id: "over-inline" }); + + expect(exact.structuredContent).toMatchObject({ kind: "inline" }); + expect(over.structuredContent).toMatchObject({ + kind: "local-artifact", + byteLength: 1024 * 1024 + 1, + }); + await expect( + manager.callTool(endpoint, "query_user", { id: "advertised-over-cap" }), + ).rejects.toMatchObject({ code: "DOWNSTREAM_PROTOCOL_ERROR" }); + await expect( + boundedManager.callTool(endpoint, "query_user", { id: "streamed-over-cap" }), + ).rejects.toMatchObject({ code: "DOWNSTREAM_PROTOCOL_ERROR" }); + await expect( + overCapOverrideManager.callTool(endpoint, "query_user", { id: "advertised-over-cap" }), + ).rejects.toMatchObject({ code: "DOWNSTREAM_PROTOCOL_ERROR" }); + } finally { + rmSync(artifactDir, { recursive: true, force: true }); + } + }); + it("redacts GraphQL auth failures without returning downstream error bodies", async () => { const manager = new GraphQLManager(registry()); const endpoint = graphqlEndpoint({ @@ -361,6 +762,12 @@ const schemaSdl = ` } `; +function graphQlPayload(byteLength: number): string { + const payload = { data: { user: { id: "42", name: "Ada" } }, padding: "" }; + const paddingLength = byteLength - Buffer.byteLength(JSON.stringify(payload)); + return JSON.stringify({ ...payload, padding: "x".repeat(paddingLength) }); +} + function graphqlEndpoint(overrides: Partial = {}): GraphqlEndpointConfig { return { server: "users", diff --git a/packages/core/test/http-actions.test.ts b/packages/core/test/http-actions.test.ts index af0eb906..ea1957e7 100644 --- a/packages/core/test/http-actions.test.ts +++ b/packages/core/test/http-actions.test.ts @@ -7,6 +7,7 @@ import { parseConfig, type HttpApiConfig } from "../src/config"; import { DownstreamManager } from "../src/downstream"; import { HttpActionManager } from "../src/http-actions"; import { ServerRegistry } from "../src/registry"; +import { CapletsEngine } from "../src/engine"; import { handleServerTool } from "../src/tools"; describe("HttpActionManager", () => { @@ -48,6 +49,14 @@ describe("HttpActionManager", () => { response.end("x".repeat(2 * 1024 * 1024)); return; } + if (request.url === "/inline-threshold") { + response.end("x".repeat(1024 * 1024)); + return; + } + if (request.url === "/over-inline-threshold") { + response.end("x".repeat(1024 * 1024 + 1)); + return; + } if (request.url === "/large-stream") { response.write("x".repeat(128)); response.write("x".repeat(128)); @@ -127,31 +136,33 @@ describe("HttpActionManager", () => { const tools = await manager.listTools(api); - expect(tools).toEqual([ - { - name: "ping", - description: "Ping the service.", - inputSchema: { type: "object", additionalProperties: true }, - annotations: { readOnlyHint: true, destructiveHint: false }, - }, - { - name: "update_user", - inputSchema: { type: "object", required: ["id"] }, - outputSchema: { - type: "object", - required: ["status", "body"], - properties: { - status: { type: "number" }, - body: { - type: "object", - required: ["ok"], - properties: { ok: { type: "boolean" } }, - }, + expect(tools).toHaveLength(2); + expect(tools[0]).toEqual({ + name: "ping", + description: "Ping the service.", + inputSchema: { type: "object", additionalProperties: true }, + annotations: { readOnlyHint: true, destructiveHint: false }, + }); + expect(tools[1]).toMatchObject({ + name: "update_user", + inputSchema: { type: "object", required: ["id"] }, + outputSchema: { + type: "object", + required: ["status", "statusText", "headers", "kind"], + properties: { + body: { + type: "object", + required: ["ok"], + properties: { ok: { type: "boolean" } }, }, + kind: { enum: ["inline", "local-artifact", "remote-reference"] }, + uri: { type: "string" }, + path: { type: "string" }, }, - annotations: { readOnlyHint: false, destructiveHint: false }, + oneOf: expect.any(Array), }, - ]); + annotations: { readOnlyHint: false, destructiveHint: false }, + }); }); it("exposes output schemas through describe_tool, compact metadata, and call_tool.fields", async () => { @@ -192,8 +203,13 @@ describe("HttpActionManager", () => { const tool = await http.getTool(caplet, "ping"); expect(tool.outputSchema).toMatchObject({ type: "object", - required: ["status", "body"], - properties: { body: { properties: { ok: { type: "boolean" } } } }, + required: ["status", "statusText", "headers", "kind"], + properties: { + body: { properties: { ok: { type: "boolean" } } }, + kind: { enum: ["inline", "local-artifact", "remote-reference"] }, + uri: { type: "string" }, + }, + oneOf: expect.any(Array), }); expect(http.compact(caplet, tool)).toMatchObject({ name: "ping", @@ -307,23 +323,15 @@ describe("HttpActionManager", () => { try { const result = await manager.callTool(api, "pdf", {}); - const structured = result.structuredContent as { - status: number; - headers: { "content-type": string }; - body: { artifact: { path: string; mimeType: string; byteLength: number } }; - }; - expect(structured).toMatchObject({ + expect(result.structuredContent).toMatchObject({ status: 200, headers: { "content-type": "application/pdf" }, - body: { - artifact: { - mimeType: "application/pdf", - byteLength: 13, - }, - }, + kind: "local-artifact", + mimeType: "application/pdf", + byteLength: 13, }); - expect(readFileSync(structured.body.artifact.path, "utf8")).toBe("%PDF-1.7 test"); + expect(readFileSync(localArtifactPath(result), "utf8")).toBe("%PDF-1.7 test"); } finally { rmSync(artifactDir, { recursive: true, force: true }); } @@ -336,25 +344,15 @@ describe("HttpActionManager", () => { try { const result = await manager.callTool(api, "attachment", {}); - const structured = result.structuredContent as { - body: { - artifact: { - uri: string; - path: string; - filename: string; - byteLength: number; - sha256: string; - }; - }; - }; - - expect(structured.body.artifact).toMatchObject({ + + expect(result.structuredContent).toMatchObject({ + kind: "local-artifact", filename: "report.bin", byteLength: 4, + uri: expect.stringMatching(/^caplets:\/\/artifacts\//u), + sha256: expect.any(String), }); - expect(structured.body.artifact.uri).toContain("caplets://artifacts/"); - expect(structured.body.artifact.sha256).toHaveLength(64); - expect(readFileSync(structured.body.artifact.path)).toEqual(Buffer.from([0, 1, 2, 3])); + expect(readFileSync(localArtifactPath(result))).toEqual(Buffer.from([0, 1, 2, 3])); } finally { rmSync(artifactDir, { recursive: true, force: true }); } @@ -378,7 +376,7 @@ describe("HttpActionManager", () => { const downstream = new DownstreamManager(registry); try { - const result = (await handleServerTool( + const result = await handleServerTool( config.httpApis.http!, { operation: "call_tool", name: "pdf", args: {} }, registry, @@ -386,12 +384,10 @@ describe("HttpActionManager", () => { undefined, undefined, http, - )) as any; - - expect(result.structuredContent.body.artifact.path).toContain(artifactDir); - expect(readFileSync(result.structuredContent.body.artifact.path, "utf8")).toBe( - "%PDF-1.7 test", ); + + expect(localArtifactPath(result)).toContain(artifactDir); + expect(readFileSync(localArtifactPath(result), "utf8")).toBe("%PDF-1.7 test"); } finally { rmSync(artifactDir, { recursive: true, force: true }); } @@ -399,37 +395,115 @@ describe("HttpActionManager", () => { it("writes oversized streamed text responses as media artifacts", async () => { const artifactDir = mkdtempSync(join(tmpdir(), "caplets-http-stream-artifacts-")); - const manager = new HttpActionManager(registry(), { artifactDir, maxInlineBytes: 100 }); + const manager = new HttpActionManager(registry(), { + artifactDir, + mediaInlineThresholdBytes: 100, + }); const api = httpApi({ actions: { large_stream: { method: "GET", path: "/large-stream" } }, }); try { const result = await manager.callTool(api, "large_stream", {}); - const structured = result.structuredContent as { - status: number; - headers: { "content-type": string }; - body: { artifact: { path: string; mimeType: string; byteLength: number } }; - }; - expect(structured).toMatchObject({ + expect(result.structuredContent).toMatchObject({ status: 200, - body: { - artifact: { - mimeType: "application/json", - byteLength: 256, + kind: "local-artifact", + mimeType: "application/json", + byteLength: 256, + }); + expect(readFileSync(localArtifactPath(result), "utf8")).toBe("x".repeat(256)); + } finally { + rmSync(artifactDir, { recursive: true, force: true }); + } + }); + + it("keeps the default inline threshold distinct from an HTTP Action hard cap", async () => { + const artifactDir = mkdtempSync(join(tmpdir(), "caplets-http-default-threshold-")); + const manager = new HttpActionManager(registry(), { artifactDir }); + const api = httpApi({ + maxResponseBytes: 2 * 1024 * 1024, + actions: { + exact: { method: "GET", path: "/inline-threshold" }, + over: { method: "GET", path: "/over-inline-threshold" }, + }, + }); + + try { + const exact = await manager.callTool(api, "exact", {}); + const over = await manager.callTool(api, "over", {}); + + expect(exact.structuredContent).toMatchObject({ kind: "inline" }); + expect(over.structuredContent).toMatchObject({ + kind: "local-artifact", + byteLength: 1024 * 1024 + 1, + }); + expect(readFileSync(localArtifactPath(over)).byteLength).toBe(1024 * 1024 + 1); + } finally { + rmSync(artifactDir, { recursive: true, force: true }); + } + }); + + it("applies the engine Media inline threshold below an action hard cap", async () => { + const artifactDir = mkdtempSync(join(tmpdir(), "caplets-engine-http-artifacts-")); + const config = parseConfig({ + httpApis: { + http: { + name: "HTTP API", + description: "Call configured HTTP service actions.", + baseUrl, + auth: { type: "none" }, + maxResponseBytes: 512, + actions: { + large_stream: { + method: "GET", + path: "/large-stream", + }, }, }, + }, + }); + const engine = new CapletsEngine({ + watch: false, + artifactDir, + mediaInlineThresholdBytes: 128, + configLoader: () => config, + }); + + try { + const progressive = await engine.execute("http", { + operation: "call_tool", + name: "large_stream", + args: {}, + }); + const direct = await engine.executeDirectTool("http", "large_stream", {}); + + expect(progressive).toMatchObject({ + structuredContent: { + kind: "local-artifact", + byteLength: 256, + path: expect.stringContaining(artifactDir), + }, + }); + expect(direct).toMatchObject({ + structuredContent: { + kind: "local-artifact", + byteLength: 256, + path: expect.stringContaining(artifactDir), + }, }); - expect(readFileSync(structured.body.artifact.path, "utf8")).toBe("x".repeat(256)); } finally { + await engine.close(); rmSync(artifactDir, { recursive: true, force: true }); } }); it("enforces maxResponseBytes as a hard response cap", async () => { const artifactDir = mkdtempSync(join(tmpdir(), "caplets-http-cap-artifacts-")); - const manager = new HttpActionManager(registry(), { artifactDir, maxInlineBytes: 100 }); + const manager = new HttpActionManager(registry(), { + artifactDir, + mediaInlineThresholdBytes: 100, + }); const api = httpApi({ maxResponseBytes: 200, actions: { large_stream: { method: "GET", path: "/large-stream" } }, @@ -595,3 +669,20 @@ function registry(): ServerRegistry { }), ); } + +function localArtifactPath(result: unknown): string { + if ( + result && + typeof result === "object" && + "structuredContent" in result && + result.structuredContent && + typeof result.structuredContent === "object" && + "kind" in result.structuredContent && + result.structuredContent.kind === "local-artifact" && + "path" in result.structuredContent && + typeof result.structuredContent.path === "string" + ) { + return result.structuredContent.path; + } + throw new Error("expected a local artifact result"); +} diff --git a/packages/core/test/mcp-test-client.ts b/packages/core/test/mcp-test-client.ts new file mode 100644 index 00000000..2164b201 --- /dev/null +++ b/packages/core/test/mcp-test-client.ts @@ -0,0 +1,42 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport"; +import type { CapletsMcpSession } from "../src/serve/session"; + +export async function connectMcpTestClient( + session: Pick, +): Promise { + const clientTransport = new LinkedTransport(); + const serverTransport = new LinkedTransport(); + clientTransport.peer = serverTransport; + serverTransport.peer = clientTransport; + + await session.connect(serverTransport); + const client = new Client({ name: "caplets-test-client", version: "1.0.0" }); + await client.connect(clientTransport); + return client; +} + +class LinkedTransport implements Transport { + peer?: LinkedTransport; + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: NonNullable; + + async start(): Promise {} + + async send(message: JSONRPCMessage): Promise { + const peer = this.peer; + if (!peer) throw new Error("linked MCP transport has no peer"); + await new Promise((resolve) => { + queueMicrotask(() => { + peer.onmessage?.(structuredClone(message)); + resolve(); + }); + }); + } + + async close(): Promise { + this.onclose?.(); + } +} diff --git a/packages/core/test/media-artifacts.test.ts b/packages/core/test/media-artifacts.test.ts index cee2373a..20ce16b1 100644 --- a/packages/core/test/media-artifacts.test.ts +++ b/packages/core/test/media-artifacts.test.ts @@ -9,7 +9,8 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import Ajv from "ajv"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { artifactUri, readMediaInput, @@ -17,6 +18,7 @@ import { writeMediaArtifact, } from "../src/media"; import { readHttpLikeResponse } from "../src/http/response"; +import { httpLikeMediaOutputSchema } from "../src/media/results"; describe("media artifacts", () => { const dirs: string[] = []; @@ -31,6 +33,75 @@ describe("media artifacts", () => { return dir; } + it("validates the three Media variants without accepting locality leaks", () => { + const schema = httpLikeMediaOutputSchema({ + type: "object", + additionalProperties: false, + required: ["status", "statusText", "headers", "body"], + properties: { + status: { type: "number" }, + statusText: { type: "string" }, + headers: { + type: "object", + additionalProperties: false, + required: ["content-type"], + properties: { "content-type": { type: "string" } }, + }, + body: { + type: "object", + additionalProperties: false, + required: ["ok"], + properties: { ok: { type: "boolean" } }, + }, + }, + }); + const validate = new Ajv({ strict: false }).compile(schema); + const response = { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }; + const facts = { + uri: "caplets://artifacts/reports/call-1/report.pdf", + filename: "report.pdf", + mimeType: "application/pdf", + byteLength: 9, + sha256: "a".repeat(64), + }; + + expect(validate({ ...response, kind: "inline", body: { ok: true } })).toBe(true); + expect( + validate({ ...response, kind: "local-artifact", ...facts, path: "/tmp/report.pdf" }), + ).toBe(true); + expect(validate({ ...response, kind: "remote-reference", ...facts })).toBe(true); + + expect( + validate({ + ...response, + kind: "inline", + body: { ok: true }, + uri: facts.uri, + }), + ).toBe(false); + expect( + validate({ + ...response, + kind: "local-artifact", + ...facts, + path: "/tmp/report.pdf", + body: { ok: true }, + }), + ).toBe(false); + expect( + validate({ + ...response, + kind: "remote-reference", + ...facts, + path: "/tmp/report.pdf", + }), + ).toBe(false); + }); + it("writes artifact files with stable metadata", async () => { const root = tempDir("caplets-artifacts-"); const artifact = await writeMediaArtifact({ @@ -156,10 +227,13 @@ describe("media artifacts", () => { }, ); - const artifact = (result.body as { artifact: { path?: string } }).artifact; expect(result.status).toBe(404); + expect(result.kind).toBe("local-artifact"); + if (result.kind !== "local-artifact") { + throw new Error("forced artifact response must retain a local artifact result"); + } expect(readFileSync(outputPath, "utf8")).toBe("previous-pdf"); - expect(artifact.path).not.toBe(outputPath); + expect(result.path).not.toBe(outputPath); }); it("cancels oversized responses rejected by content-length before reading", async () => { @@ -185,6 +259,36 @@ describe("media artifacts", () => { expect(cancelled).toBe(true); }); + it("cancels locked response readers when semantic inspection fails", async () => { + const root = tempDir("caplets-artifacts-"); + const inspectionFailure = new Error("semantic inspection failed"); + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"data":"streaming"}')); + }, + cancel() { + cancelled = true; + }, + }); + + await expect( + readHttpLikeResponse( + new Response(body, { headers: { "content-type": "application/json" } }), + { + capletId: "drive", + artifactDir: root, + maxInlineBytes: 0, + inspectChunk: () => { + throw inspectionFailure; + }, + }, + ), + ).rejects.toBe(inspectionFailure); + + expect(cancelled).toBe(true); + }); + it("rejects oversized artifact and data URL inputs before reading decoded bytes", async () => { const root = tempDir("caplets-artifacts-"); const artifact = await writeMediaArtifact({ @@ -244,6 +348,27 @@ describe("media artifacts", () => { ).rejects.toMatchObject({ code: "REQUEST_INVALID" }); }); + it("rejects symlinked artifact metadata sidecars", async () => { + const root = tempDir("caplets-artifacts-"); + const outside = tempDir("caplets-artifacts-outside-"); + const outputPath = join(root, "drive", "call-1", "report.pdf"); + const outsideFile = join(outside, "metadata.json"); + mkdirSync(join(root, "drive", "call-1"), { recursive: true }); + writeFileSync(outsideFile, "outside"); + symlinkSync(outsideFile, `${outputPath}.caplets.json`); + + await expect( + writeMediaArtifact({ + rootDir: root, + capletId: "drive", + outputPath, + mimeType: "application/pdf", + bytes: Buffer.from("report"), + }), + ).rejects.toMatchObject({ code: "REQUEST_INVALID" }); + expect(readFileSync(outsideFile, "utf8")).toBe("outside"); + }); + it("rejects symlinked artifact roots", async () => { const realRoot = tempDir("caplets-artifacts-real-"); const parent = tempDir("caplets-artifacts-parent-"); @@ -342,6 +467,74 @@ describe("media artifacts", () => { }); }); + it("returns discriminated local artifacts and remote references without fabricating paths", async () => { + const root = tempDir("caplets-artifacts-"); + const options = { + capletId: "drive", + artifactDir: root, + maxInlineBytes: 1, + maxBytes: 16, + }; + const local = await readHttpLikeResponse( + new Response(Buffer.from("pdf-bytes"), { + headers: { "content-type": "application/pdf" }, + }), + options, + ); + const remote = await readHttpLikeResponse( + new Response(Buffer.from("pdf-bytes"), { + headers: { "content-type": "application/pdf" }, + }), + { ...options, exposeLocalPath: false }, + ); + + expect(local).toMatchObject({ + kind: "local-artifact", + path: expect.stringContaining(root), + filename: "response.bin", + mimeType: "application/pdf", + byteLength: 9, + sha256: expect.any(String), + }); + expect(remote).toMatchObject({ + kind: "remote-reference", + uri: expect.stringMatching(/^caplets:\/\/artifacts\//u), + filename: "response.bin", + mimeType: "application/pdf", + byteLength: 9, + sha256: expect.any(String), + }); + expect(remote).not.toHaveProperty("path"); + expect(remote).not.toHaveProperty("pathResolution"); + }); + + it("does not decode artifact bytes without a semantic inspector", async () => { + const root = tempDir("caplets-artifacts-"); + let decoded = false; + vi.stubGlobal( + "TextDecoder", + class { + decode(): string { + decoded = true; + throw new Error("artifact bytes must not be decoded"); + } + }, + ); + + try { + const result = await readHttpLikeResponse( + new Response(Buffer.from([0, 255, 128, 64]), { + headers: { "content-type": "application/octet-stream" }, + }), + { capletId: "drive", artifactDir: root }, + ); + + expect(result.kind).toBe("local-artifact"); + expect(decoded).toBe(false); + } finally { + vi.unstubAllGlobals(); + } + }); it("rejects multiple media input sources and non-base64 data URLs", async () => { const root = tempDir("caplets-artifacts-"); await expect( diff --git a/packages/core/test/native.test.ts b/packages/core/test/native.test.ts index f44c9271..56dadc64 100644 --- a/packages/core/test/native.test.ts +++ b/packages/core/test/native.test.ts @@ -1,6 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -319,6 +320,59 @@ describe("native Caplets service", () => { } }); + it("returns local HTTP artifacts with an absolute managed path in the native service", async () => { + const http = await startPdfServer(); + let artifactCallDir: string | undefined; + try { + const { dir, configPath, projectConfigPath } = tempConfig({ + httpApis: { + status: { + name: "Status HTTP", + description: "Download a local report.", + exposure: "direct", + baseUrl: http.baseUrl, + auth: { type: "none" }, + actions: { download: { method: "GET", path: "/report" } }, + }, + }, + }); + dirs.push(dir); + const service = createNativeCapletsService({ configPath, projectConfigPath }); + + try { + expect(service.listTools()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + caplet: "status__download", + toolName: "caplets__status__download", + }), + ]), + ); + const result = await service.execute("status__download", {}); + const path = localArtifactPath(result); + artifactCallDir = dirname(path); + + expect(isAbsolute(path)).toBe(true); + expect(readFileSync(path, "utf8")).toBe("%PDF-1.7 native"); + expect(result).toMatchObject({ + structuredContent: { + kind: "local-artifact", + path, + mimeType: "application/pdf", + byteLength: 15, + }, + }); + } finally { + await service.close(); + } + } finally { + if (artifactCallDir) { + rmSync(artifactCallDir, { recursive: true, force: true }); + } + await http.close(); + } + }); + it("lists and executes local project-bound CLI tools when native project context is supplied", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ cliTools: { @@ -994,6 +1048,42 @@ describe("native Caplets service", () => { } }); +async function startPdfServer(): Promise<{ baseUrl: string; close: () => Promise }> { + const server = createServer((_request, response) => { + response.setHeader("content-type", "application/pdf"); + response.end(Buffer.from("%PDF-1.7 native")); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolve) => server.close(() => resolve())); + throw new Error("native HTTP test server did not bind"); + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function localArtifactPath(result: unknown): string { + if ( + result && + typeof result === "object" && + "structuredContent" in result && + result.structuredContent && + typeof result.structuredContent === "object" && + "kind" in result.structuredContent && + result.structuredContent.kind === "local-artifact" && + "path" in result.structuredContent && + typeof result.structuredContent.path === "string" + ) { + return result.structuredContent.path; + } + throw new Error("expected a local artifact result"); +} + function progressiveTestConfig(config: unknown): unknown { if (!config || typeof config !== "object" || Array.isArray(config)) return config; const record = config as Record; diff --git a/packages/core/test/openapi.test.ts b/packages/core/test/openapi.test.ts index 47e3e37e..6088d790 100644 --- a/packages/core/test/openapi.test.ts +++ b/packages/core/test/openapi.test.ts @@ -243,7 +243,7 @@ describe("native OpenAPI Caplets", () => { }); expect(tool.structuredContent.result.tool.outputSchema).toMatchObject({ type: "object", - required: ["status", "statusText", "headers"], + required: ["status", "statusText", "headers", "kind"], properties: { status: { type: "number" }, statusText: { type: "string" }, @@ -260,7 +260,11 @@ describe("native OpenAPI Caplets", () => { name: { type: "string" }, }, }, + kind: { enum: ["inline", "local-artifact", "remote-reference"] }, + uri: { type: "string" }, + path: { type: "string" }, }, + oneOf: expect.any(Array), }); const result = (await handleServerTool( @@ -990,23 +994,15 @@ describe("native OpenAPI Caplets", () => { const result = await openapi.callTool(config.openapiEndpoints.reports!, "getReport", { path: { id: "42" }, }); - const structured = result.structuredContent as { - status: number; - headers: { "content-type": string }; - body: { artifact: { path: string; mimeType: string; byteLength: number } }; - }; - expect(structured).toMatchObject({ + expect(result.structuredContent).toMatchObject({ status: 200, headers: { "content-type": "application/pdf" }, - body: { - artifact: { - mimeType: "application/pdf", - byteLength: 13, - }, - }, + kind: "local-artifact", + mimeType: "application/pdf", + byteLength: 13, }); - expect(readFileSync(structured.body.artifact.path, "utf8")).toBe("%PDF-1.7 test"); + expect(readFileSync(localArtifactPath(result), "utf8")).toBe("%PDF-1.7 test"); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -1035,21 +1031,14 @@ describe("native OpenAPI Caplets", () => { const result = await openapi.callTool(config.openapiEndpoints.reports!, "getReport", { path: { id: "large" }, }); - const structured = result.structuredContent as { - status: number; - body: { artifact: { path: string; mimeType: string; byteLength: number } }; - }; - expect(structured).toMatchObject({ + expect(result.structuredContent).toMatchObject({ status: 200, - body: { - artifact: { - mimeType: "application/pdf", - byteLength: 1024 * 1024 + 1, - }, - }, + kind: "local-artifact", + mimeType: "application/pdf", + byteLength: 1024 * 1024 + 1, }); - expect(readFileSync(structured.body.artifact.path).byteLength).toBe(1024 * 1024 + 1); + expect(readFileSync(localArtifactPath(result)).byteLength).toBe(1024 * 1024 + 1); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -1300,3 +1289,20 @@ function headerDefaultSpec(baseUrl: string) { }, }; } + +function localArtifactPath(result: unknown): string { + if ( + result && + typeof result === "object" && + "structuredContent" in result && + result.structuredContent && + typeof result.structuredContent === "object" && + "kind" in result.structuredContent && + result.structuredContent.kind === "local-artifact" && + "path" in result.structuredContent && + typeof result.structuredContent.path === "string" + ) { + return result.structuredContent.path; + } + throw new Error("expected a local artifact result"); +} diff --git a/packages/core/test/result-content.test.ts b/packages/core/test/result-content.test.ts index 1c97d456..e094609d 100644 --- a/packages/core/test/result-content.test.ts +++ b/packages/core/test/result-content.test.ts @@ -70,18 +70,23 @@ describe("result content helpers", () => { expect(text).toContain('"matches": []'); }); - it("preserves downstream MCP text without duplicating structured content", () => { + it("preserves mixed downstream MCP blocks in order without adding structured-content prose", () => { + const downstreamContent = [ + { type: "text", text: "Downstream text" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + { type: "resource_link", uri: "file:///tmp/report.pdf", name: "Report" }, + ]; const content = markdownCallToolResultContent( { - content: [{ type: "text", text: "Downstream text" }], + content: downstreamContent, structuredContent: { snapshot: { title: "Example" } }, + isError: true, }, { title: "Browser call_tool browser_snapshot", backend: "mcp" }, ); - expect(content[0]?.text).toContain("Downstream text"); - expect(content).toHaveLength(1); - expect(content[0]?.text).not.toContain("## Structured Content"); + expect(content).toEqual(downstreamContent); + expect(content).toHaveLength(3); }); it("detects renderable structured content while ignoring metadata-only objects", () => { diff --git a/packages/core/test/runtime.test.ts b/packages/core/test/runtime.test.ts index bbd78f20..9b0a495b 100644 --- a/packages/core/test/runtime.test.ts +++ b/packages/core/test/runtime.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import type { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp"; import { nativeCapletToolName } from "../src/native"; import { CapletsRuntime } from "../src/runtime"; @@ -91,6 +92,70 @@ describe("CapletsRuntime", () => { await runtime.close(); }); + it("returns local HTTP artifacts with an absolute managed path in the local runtime", async () => { + const http = await startPdfServer(); + try { + const { dir, configPath, projectConfigPath } = tempConfig({ + httpApis: { + status: { + name: "Status HTTP", + description: "Download a local report.", + baseUrl: http.baseUrl, + auth: { type: "none" }, + actions: { download: { method: "GET", path: "/report" } }, + }, + }, + }); + dirs.push(dir); + const artifactDir = join(dir, "artifacts"); + const server = mockServer(); + const runtime = new CapletsRuntime({ + configPath, + projectConfigPath, + artifactDir, + server, + }); + + try { + const handler = server.handlers.get("status"); + expect(handler).toBeDefined(); + const result = await handler!({ + operation: "call_tool", + name: "download", + args: {}, + }); + const path = localArtifactPath(result); + + expect(path).toContain(artifactDir); + expect(isAbsolute(path)).toBe(true); + expect(readFileSync(path, "utf8")).toBe("%PDF-1.7 runtime"); + expect(result).toMatchObject({ + structuredContent: { + kind: "local-artifact", + path, + mimeType: "application/pdf", + byteLength: 16, + }, + _meta: { + caplets: { + artifacts: [ + { + presentation: "local-path", + displayPath: path, + pathResolution: "absolute", + }, + ], + }, + }, + }); + } finally { + await runtime.close(); + } + } finally { + await http.close(); + } + }); + it("registers CLI tools Caplets", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ cliTools: { @@ -350,9 +415,12 @@ function progressiveTestConfig(config: unknown): unknown { function mockServer() { const registered = new Map(); + const handlers = new Map Promise>(); return { registered, - registerTool: vi.fn((name: string) => { + handlers, + registerTool: vi.fn((name: string, ...args: unknown[]) => { + const handler = args[1]; const tool = { update: vi.fn(), remove: vi.fn(() => registered.delete(name)), @@ -361,6 +429,9 @@ function mockServer() { enabled: true, handler: vi.fn(), } as unknown as RegisteredTool; + if (typeof handler === "function") { + handlers.set(name, async (request) => await Reflect.apply(handler, undefined, [request])); + } registered.set(name, tool); return tool; }), @@ -368,3 +439,39 @@ function mockServer() { close: vi.fn(async () => {}), }; } + +async function startPdfServer(): Promise<{ baseUrl: string; close: () => Promise }> { + const server = createServer((_request, response) => { + response.setHeader("content-type", "application/pdf"); + response.end(Buffer.from("%PDF-1.7 runtime")); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolve) => server.close(() => resolve())); + throw new Error("runtime HTTP test server did not bind"); + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function localArtifactPath(result: unknown): string { + if ( + result && + typeof result === "object" && + "structuredContent" in result && + result.structuredContent && + typeof result.structuredContent === "object" && + "kind" in result.structuredContent && + result.structuredContent.kind === "local-artifact" && + "path" in result.structuredContent && + typeof result.structuredContent.path === "string" + ) { + return result.structuredContent.path; + } + throw new Error("expected a local artifact result"); +} diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index 3b419dd4..c24ac436 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -1,16 +1,25 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { serve, type WebSocketServerLike } from "@hono/node-server"; import { afterEach, describe, expect, it, vi } from "vitest"; import WebSocket, { WebSocketServer } from "ws"; import { CapletsEngine } from "../src/engine"; +import type { CapletsEngineOptions } from "../src/engine"; import { CapletsError } from "../src/errors"; import { RemoteAuthFlowStore } from "../src/remote-control/auth-flow"; import { RemoteServerCredentialStore } from "../src/remote/server-credential-store"; +import { FileRemoteProfileStore } from "../src/remote/profile-store"; import type { ProjectBindingLease } from "../src/project-binding"; import { ProjectBindingWorkspaceStore } from "../src/project-binding/workspaces"; -import { CAPLETS_STACK_CHAIN_HEADER, createHttpServeApp } from "../src/serve/http"; +import { + CAPLETS_STACK_CHAIN_HEADER, + createHttpServeApp, + sanitizeRemoteEngineOptions, +} from "../src/serve/http"; +import * as serveHttpModule from "../src/serve/http"; +import type { HttpAttachSessionFactory, HttpMcpSessionFactory } from "../src/serve/http"; import { CAPLETS_ATTACH_SESSION_HEADER, type AttachManifest } from "../src/attach/api"; import type { HttpServeOptions } from "../src/serve/options"; @@ -22,6 +31,20 @@ afterEach(() => { } }); +it("forces remote artifact paths off after caller engine options", () => { + const options = sanitizeRemoteEngineOptions({ + artifactDir: "/tmp/caplets-artifacts", + exposeLocalArtifactPaths: true, + mediaInlineThresholdBytes: 128, + }); + + expect(options).toMatchObject({ + artifactDir: "/tmp/caplets-artifacts", + exposeLocalArtifactPaths: false, + mediaInlineThresholdBytes: 128, + vaultRecoveryTarget: "remote", + }); +}); describe("createHttpServeApp", () => { it("serves root info and health without auth", async () => { const { engine } = testEngine(); @@ -1663,6 +1686,139 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("keeps caller-enabled artifact paths out of stacked MCP and Attach results", async () => { + const downstream = await startPdfServer(); + const upstreamContext = testContext(); + const upstreamEngine = new CapletsEngine({ + configPath: upstreamContext.configPath, + projectConfigPath: upstreamContext.projectConfigPath, + watch: false, + }); + const upstreamApp = createHttpServeApp(httpOptions(), upstreamEngine, { writeErr: () => {} }); + const upstream = await startTestHttpServer(upstreamApp); + let captured: CapturedUpstreamServe | undefined; + const captureSessionFactory = async ( + _options: HttpServeOptions, + sessionFactory: HttpMcpSessionFactory, + _writeErr: ((value: string) => void) | undefined, + io: { + attachSessionFactory?: HttpAttachSessionFactory; + defaultAttachSessionFactory?: HttpAttachSessionFactory; + exposeAttach?: boolean; + }, + engineOptions: CapletsEngineOptions, + ): Promise => { + captured = { + sessionFactory, + ...(io.attachSessionFactory ? { attachSessionFactory: io.attachSessionFactory } : {}), + ...(io.defaultAttachSessionFactory + ? { defaultAttachSessionFactory: io.defaultAttachSessionFactory } + : {}), + ...(io.exposeAttach === undefined ? {} : { exposeAttach: io.exposeAttach }), + engineOptions, + }; + }; + + vi.resetModules(); + vi.doMock("../src/serve/http", () => ({ + ...serveHttpModule, + serveHttpWithSessionFactory: captureSessionFactory, + })); + try { + const wrapperContext = testContext(); + const authDir = tempDir("caplets-stacked-auth-"); + await new FileRemoteProfileStore({ + root: join(authDir, "remote-profiles"), + }).saveSelfHostedProfile({ + hostUrl: upstream.origin, + clientId: "stacked_test_client", + clientLabel: "Stacked Test", + credentials: { + accessToken: "stacked-access-token", + refreshToken: "stacked-refresh-token", + tokenType: "Bearer", + expiresAt: "2999-01-01T00:00:00.000Z", + }, + }); + const artifactDir = tempDir("caplets-stacked-artifacts-"); + writeFileSync( + wrapperContext.configPath, + JSON.stringify({ + options: { exposure: "direct" }, + httpApis: { + overlay: { + name: "Overlay HTTP", + description: "Download a stacked runtime report.", + exposure: "direct", + baseUrl: downstream.baseUrl, + auth: { type: "none" }, + actions: { download: { method: "GET", path: "/report" } }, + }, + }, + }), + ); + + // The module must load after the mock so the public upstream entrypoint builds its closures. + const { serveResolvedCaplets } = await import("../src/serve"); + await serveResolvedCaplets( + httpOptions({ port: 5399, upstreamUrl: upstream.origin }), + { + configPath: wrapperContext.configPath, + projectConfigPath: wrapperContext.projectConfigPath, + authDir, + artifactDir, + exposeLocalArtifactPaths: true, + watch: false, + }, + () => {}, + ); + if (!captured) throw new Error("expected stacked serve wiring to capture session factories"); + + const wrapperEngine = new CapletsEngine(captured.engineOptions); + const wrapperApp = createHttpServeApp(httpOptions({ loopback: false }), wrapperEngine, { + writeErr: () => {}, + ...(captured.exposeAttach === undefined ? {} : { exposeAttach: captured.exposeAttach }), + sessionFactory: captured.sessionFactory, + ...(captured.attachSessionFactory + ? { attachSessionFactory: captured.attachSessionFactory } + : {}), + ...(captured.defaultAttachSessionFactory + ? { defaultAttachSessionFactory: captured.defaultAttachSessionFactory } + : {}), + }); + const wrapper = await startTestHttpServer(wrapperApp); + try { + expectRemoteArtifactResult(await callMcpTool(wrapper.origin, "overlay__download", {})); + + expectRemoteArtifactResult(await invokeAttachTool(wrapper.origin)); + + const created = await fetch(`${wrapper.origin}/v1/attach/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + expect(created.status).toBe(201); + const sessionId = attachSessionId(await created.json()); + expectRemoteArtifactResult( + await invokeAttachTool(wrapper.origin, { + [CAPLETS_ATTACH_SESSION_HEADER]: sessionId, + }), + ); + } finally { + await wrapperApp.closeCapletsSessions(); + await withTimeout(wrapper.close(), "close stacked HTTP server"); + await wrapperEngine.close(); + } + } finally { + vi.doUnmock("../src/serve/http"); + vi.resetModules(); + await upstreamApp.closeCapletsSessions(); + await withTimeout(upstream.close(), "close upstream HTTP server"); + await upstreamEngine.close(); + await downstream.close(); + } + }); + it("rejects attach requests that would cycle through the same stacked runtime", async () => { const { engine } = testEngine(); const app = createHttpServeApp(httpOptions(), engine, { @@ -2721,3 +2877,207 @@ function testContext(options: { oauth?: boolean } & Record = {} ); return { configPath, projectConfigPath, projectCapletsRoot: projectRoot }; } + +type CapturedUpstreamServe = { + sessionFactory: HttpMcpSessionFactory; + attachSessionFactory?: HttpAttachSessionFactory; + defaultAttachSessionFactory?: HttpAttachSessionFactory; + exposeAttach?: boolean; + engineOptions: CapletsEngineOptions; +}; + +async function startPdfServer(): Promise<{ baseUrl: string; close: () => Promise }> { + const server = createServer((_request, response) => { + response.setHeader("content-type", "application/pdf"); + response.end(Buffer.from("%PDF-1.7 stacked")); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolve) => server.close(() => resolve())); + throw new Error("stacked HTTP artifact server did not bind"); + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +async function callMcpTool( + origin: string, + name: string, + args: Record, +): Promise { + const headers = { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }; + const initialized = await fetch(`${origin}/v1/mcp`, { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "host-boundary-test", version: "1.0.0" }, + }, + }), + }); + expect(initialized.status).toBe(200); + const sessionId = initialized.headers.get("mcp-session-id"); + if (!sessionId) throw new Error("expected MCP session ID"); + try { + await fetch(`${origin}/v1/mcp`, { + method: "POST", + headers: { + ...headers, + "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + }); + const response = await fetch(`${origin}/v1/mcp`, { + method: "POST", + headers: { + ...headers, + "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name, arguments: args }, + }), + }); + expect(response.status).toBe(200); + return mcpToolCallResult(await response.text()); + } finally { + const deleted = await fetch(`${origin}/v1/mcp`, { + method: "DELETE", + headers: { + "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", + }, + }); + expect(deleted.status).toBe(200); + } +} + +function mcpToolCallResult(text: string): unknown { + const payloadText = text.trimStart().startsWith("{") + ? text + : text + .split("\n") + .find((line) => line.startsWith("data:")) + ?.slice("data:".length) + .trim(); + if (!payloadText) throw new Error(`Could not parse MCP response: ${text}`); + const payload: unknown = JSON.parse(payloadText); + if (!isRecord(payload) || !("result" in payload)) { + throw new Error("MCP tool call did not return a result"); + } + return payload.result; +} + +function attachToolInvocation( + manifest: unknown, + name: string, +): { revision: string; exportId: string } { + if ( + !isRecord(manifest) || + typeof manifest.revision !== "string" || + !Array.isArray(manifest.tools) + ) { + throw new Error("expected Attach manifest"); + } + const tool = manifest.tools.find( + (candidate): candidate is Record => + isRecord(candidate) && candidate.name === name && typeof candidate.exportId === "string", + ); + if (!tool) throw new Error(`expected Attach export ${name}`); + const exportId = tool.exportId; + if (typeof exportId !== "string") throw new Error(`expected Attach export ID for ${name}`); + return { revision: manifest.revision, exportId }; +} + +function attachResponseData(response: unknown): unknown { + if (!isRecord(response) || response.ok !== true || !("data" in response)) { + throw new Error("expected successful Attach response"); + } + return response.data; +} + +async function invokeAttachTool( + origin: string, + headers: Record = {}, +): Promise { + const manifestResponse = await fetch(`${origin}/v1/attach/manifest`, { headers }); + expect(manifestResponse.status).toBe(200); + const attachExport = attachToolInvocation(await manifestResponse.json(), "overlay__download"); + const invokeResponse = await fetch(`${origin}/v1/attach/invoke`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + revision: attachExport.revision, + kind: "tool", + exportId: attachExport.exportId, + input: {}, + }), + }); + expect(invokeResponse.status).toBe(200); + return attachResponseData(await invokeResponse.json()); +} + +function attachSessionId(response: unknown): string { + if (!isRecord(response) || typeof response.sessionId !== "string") { + throw new Error("expected Attach session ID"); + } + return response.sessionId; +} + +function expectRemoteArtifactResult(result: unknown): void { + const structuredContent = remoteArtifact(result); + const reference = artifactReference(result); + expect(structuredContent).toMatchObject({ + kind: "remote-reference", + uri: expect.stringMatching(/^caplets:\/\/artifacts\//u), + mimeType: "application/pdf", + byteLength: 16, + }); + expect(structuredContent).not.toHaveProperty("path"); + expect(structuredContent).not.toHaveProperty("pathResolution"); + expect(reference).toMatchObject({ + presentation: "reference", + reference: structuredContent.uri, + }); + expect(reference).not.toHaveProperty("path"); + expect(reference).not.toHaveProperty("pathResolution"); +} + +function remoteArtifact(result: unknown): Record { + if (isRecord(result) && isRecord(result.structuredContent)) { + return result.structuredContent; + } + throw new Error("expected structured artifact content"); +} + +function artifactReference(result: unknown): Record { + if (!isRecord(result) || !isRecord(result._meta) || !isRecord(result._meta.caplets)) { + throw new Error("expected Caplets result metadata"); + } + const artifacts = result._meta.caplets.artifacts; + if (Array.isArray(artifacts) && isRecord(artifacts[0])) { + return artifacts[0]; + } + throw new Error("expected artifact reference metadata"); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/core/test/serve-session.test.ts b/packages/core/test/serve-session.test.ts index 782ac5ae..ea9bf7d6 100644 --- a/packages/core/test/serve-session.test.ts +++ b/packages/core/test/serve-session.test.ts @@ -1,10 +1,13 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CapletsEngine } from "../src/engine"; import { CapletsMcpSession } from "../src/serve/session"; +import { sanitizeRemoteEngineOptions } from "../src/serve/http"; +import { connectMcpTestClient } from "./mcp-test-client"; const dirs: string[] = []; @@ -156,15 +159,172 @@ describe("CapletsMcpSession", () => { expect(server.registered.get("code_mode")).toBeUndefined(); expect(server.definitions.get("status__ping")).toMatchObject({ description: "Ping the service.", - inputSchema: { - type: "object", - properties: { verbose: { type: "boolean" } }, - }, + inputSchema: expect.objectContaining({ safeParse: expect.any(Function) }), }); await session.close(); await engine.close(); }); + + it("validates remote artifact references through a real direct MCP registration", async () => { + const http = await startPdfServer(); + try { + const outputSchema = strictHttpOutputSchema(); + const { dir, configPath, projectConfigPath } = tempConfig({ + options: { exposure: "direct" }, + httpApis: { + status: { + name: "Status HTTP", + description: "Download a remote-safe report.", + exposure: "direct", + baseUrl: http.baseUrl, + auth: { type: "none" }, + actions: { + status: { + method: "GET", + path: "/status", + outputSchema, + }, + download: { + method: "GET", + path: "/report", + outputSchema, + }, + }, + }, + }, + }); + dirs.push(dir); + const engine = new CapletsEngine( + sanitizeRemoteEngineOptions({ + configPath, + projectConfigPath, + artifactDir: join(dir, "artifacts"), + exposeLocalArtifactPaths: true, + watch: false, + }), + ); + const session = new CapletsMcpSession(engine); + const client = await connectMcpTestClient(session); + + try { + const listed = await client.listTools(); + const tool = listed.tools.find((candidate) => candidate.name === "status__download"); + expect(tool?.outputSchema).toMatchObject({ + properties: { + body: { type: "object" }, + kind: { enum: ["inline", "local-artifact", "remote-reference"] }, + uri: { type: "string" }, + }, + }); + const inline = await client.callTool({ + name: "status__status", + arguments: {}, + }); + expect(remoteArtifact(inline)).toMatchObject({ + kind: "inline", + body: { ok: true }, + }); + const result = await client.callTool({ + name: "status__download", + arguments: {}, + }); + const structuredContent = remoteArtifact(result); + const reference = artifactReference(result); + + expect(structuredContent).toMatchObject({ + kind: "remote-reference", + uri: expect.stringMatching(/^caplets:\/\/artifacts\//u), + mimeType: "application/pdf", + byteLength: 16, + }); + expect(structuredContent).not.toHaveProperty("path"); + expect(structuredContent).not.toHaveProperty("pathResolution"); + expect(reference).toMatchObject({ + presentation: "reference", + reference: structuredContent.uri, + }); + expect(reference).not.toHaveProperty("path"); + expect(reference).not.toHaveProperty("pathResolution"); + } finally { + await client.close(); + await session.close(); + await engine.close(); + } + } finally { + await http.close(); + } + }); + + it("validates Discovery media references through a real direct MCP registration", async () => { + const google = await startGooglePdfServer(); + try { + const { dir, configPath, projectConfigPath } = tempConfig({ + options: { exposure: "direct" }, + googleDiscoveryApis: { + drive: { + name: "Google Drive", + description: "Download a remote-safe Drive report.", + exposure: "direct", + discoveryUrl: `${google.baseUrl}/discovery.json`, + baseUrl: `${google.baseUrl}/`, + auth: { type: "none" }, + }, + }, + }); + dirs.push(dir); + const engine = new CapletsEngine( + sanitizeRemoteEngineOptions({ + configPath, + projectConfigPath, + artifactDir: join(dir, "artifacts"), + exposeLocalArtifactPaths: true, + watch: false, + }), + ); + const session = new CapletsMcpSession(engine); + const client = await connectMcpTestClient(session); + + try { + const name = "drive__drive.files.download"; + const listed = await client.listTools(); + const tool = listed.tools.find((candidate) => candidate.name === name); + expect(tool?.outputSchema).toMatchObject({ + properties: { + body: { type: "object" }, + kind: { enum: ["inline", "local-artifact", "remote-reference"] }, + uri: { type: "string" }, + }, + }); + const result = await client.callTool({ + name, + arguments: { filename: "report.pdf" }, + }); + const structuredContent = remoteArtifact(result); + const reference = artifactReference(result); + + expect(structuredContent).toMatchObject({ + kind: "remote-reference", + uri: expect.stringMatching(/^caplets:\/\/artifacts\//u), + mimeType: "application/pdf", + }); + expect(structuredContent).not.toHaveProperty("path"); + expect(structuredContent).not.toHaveProperty("pathResolution"); + expect(reference).toMatchObject({ + presentation: "reference", + reference: structuredContent.uri, + }); + expect(reference).not.toHaveProperty("path"); + expect(reference).not.toHaveProperty("pathResolution"); + } finally { + await client.close(); + await session.close(); + await engine.close(); + } + } finally { + await google.close(); + } + }); }); function tempConfig(config: unknown): { @@ -197,10 +357,14 @@ function progressiveTestConfig(config: unknown): unknown { function mockServer() { const registered = new Map(); const definitions = new Map>(); + const handlers = new Map Promise>(); return { registered, definitions, - registerTool: vi.fn((name: string, definition: Record) => { + handlers, + registerTool: vi.fn((name: string, ...args: unknown[]) => { + const definition = args[0]; + const handler = args[1]; const tool = { update: vi.fn(), remove: vi.fn(() => registered.delete(name)), @@ -209,8 +373,11 @@ function mockServer() { enabled: true, handler: vi.fn(), } as unknown as RegisteredTool; + if (typeof handler === "function") { + handlers.set(name, async (request) => await Reflect.apply(handler, undefined, [request])); + } registered.set(name, tool); - definitions.set(name, definition); + if (isRecord(definition)) definitions.set(name, definition); return tool; }), registerResource: vi.fn(), @@ -219,3 +386,128 @@ function mockServer() { close: vi.fn(async () => {}), }; } + +function strictHttpOutputSchema(): Record { + return { + type: "object", + additionalProperties: false, + required: ["status", "statusText", "headers", "body"], + properties: { + status: { type: "number" }, + statusText: { type: "string" }, + headers: { + type: "object", + additionalProperties: false, + required: ["content-type"], + properties: { "content-type": { type: "string" } }, + }, + body: { type: "object" }, + }, + }; +} + +async function startPdfServer(): Promise<{ baseUrl: string; close: () => Promise }> { + const server = createServer((request, response) => { + if (request.url === "/status") { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ ok: true })); + return; + } + response.setHeader("content-type", "application/pdf"); + response.end(Buffer.from("%PDF-1.7 session")); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolve) => server.close(() => resolve())); + throw new Error("session HTTP test server did not bind"); + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +async function startGooglePdfServer(): Promise<{ + baseUrl: string; + close: () => Promise; +}> { + let baseUrl = ""; + const server = createServer((request, response) => { + if (request.url === "/discovery.json") { + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + kind: "discovery#restDescription", + rootUrl: `${baseUrl}/`, + servicePath: "", + schemas: { + File: { + id: "File", + type: "object", + properties: { id: { type: "string" } }, + }, + }, + resources: { + files: { + methods: { + download: { + id: "drive.files.download", + path: "report", + httpMethod: "GET", + supportsMediaDownload: true, + response: { $ref: "File" }, + }, + }, + }, + }, + }), + ); + return; + } + if (request.url === "/report" || request.url === "/report?alt=media") { + response.setHeader("content-type", "application/pdf"); + response.end(Buffer.from("%PDF-1.7 discovery")); + return; + } + response.statusCode = 404; + response.end("not found"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolve) => server.close(() => resolve())); + throw new Error("Discovery session test server did not bind"); + } + baseUrl = `http://127.0.0.1:${address.port}`; + return { + baseUrl, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function remoteArtifact(result: unknown): Record { + if (isRecord(result) && isRecord(result.structuredContent)) { + return result.structuredContent; + } + throw new Error("expected structured artifact content"); +} + +function artifactReference(result: unknown): Record { + if (!isRecord(result) || !isRecord(result._meta) || !isRecord(result._meta.caplets)) { + throw new Error("expected Caplets result metadata"); + } + const artifacts = result._meta.caplets.artifacts; + if (Array.isArray(artifacts) && isRecord(artifacts[0])) { + return artifacts[0]; + } + throw new Error("expected artifact reference metadata"); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/core/test/tools.test.ts b/packages/core/test/tools.test.ts index 29c600a4..dc26d90a 100644 --- a/packages/core/test/tools.test.ts +++ b/packages/core/test/tools.test.ts @@ -890,6 +890,47 @@ describe("generated tool handlers", () => { }); }); + it("preserves ordered downstream MCP blocks, errors, structured content, and metadata", async () => { + const downstreamResult = { + content: [ + { type: "text", text: "before" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + { type: "resource_link", uri: "file:///tmp/report.pdf", name: "Report" }, + ], + structuredContent: { snapshot: { complete: true } }, + isError: true, + _meta: { requestId: "req-mixed", trace: { id: "trace-1" } }, + }; + const downstream = { + callTool: vi.fn().mockResolvedValue(downstreamResult), + } as unknown as DownstreamManager; + + const result = await handleServerTool( + server, + { operation: "call_tool", name: "read", args: {} }, + registry, + downstream, + ); + + expect(result).toEqual({ + ...downstreamResult, + content: downstreamResult.content, + _meta: { + requestId: "req-mixed", + trace: { id: "trace-1" }, + caplets: { + id: "alpha", + name: "Alpha", + backend: "mcp", + operation: "call_tool", + tool: "read", + status: "error", + elapsedMs: expect.any(Number), + }, + }, + }); + }); + it("annotates downstream error call_tool results without changing content", async () => { const downstreamResult = { content: [{ type: "text" as const, text: "failed" }], @@ -932,6 +973,7 @@ describe("generated tool handlers", () => { expect(result._meta.caplets.artifacts).toEqual([ { kind: "screenshot", + presentation: "local-path", displayPath: "./file.png", pathResolution: "relative-to-mcp-server", }, @@ -944,6 +986,7 @@ describe("generated tool handlers", () => { expect(result._meta.caplets.artifacts).toEqual([ { kind: "snapshot", + presentation: "local-path", displayPath: "./snapshot.yaml", pathResolution: "relative-to-mcp-server", }, @@ -956,6 +999,7 @@ describe("generated tool handlers", () => { expect(result._meta.caplets.artifacts).toEqual([ { kind: "console-log", + presentation: "local-path", displayPath: "./browser-console.txt", pathResolution: "relative-to-mcp-server", }, @@ -970,38 +1014,38 @@ describe("generated tool handlers", () => { expect(result._meta.caplets.artifacts).toEqual([ { kind: "screenshot", + presentation: "local-path", displayPath: "./screen.png", pathResolution: "relative-to-mcp-server", }, { kind: "network-log", + presentation: "local-path", displayPath: "/tmp/network.har", pathResolution: "absolute", }, { kind: "file", + presentation: "local-path", displayPath: "files/report.pdf", pathResolution: "relative-to-mcp-server", }, ]); }); - it("extracts structured artifact envelopes from call_tool results", async () => { + it("presents canonical local artifacts as local paths", async () => { const downstream = { callTool: vi.fn().mockResolvedValue({ content: [{ type: "text" as const, text: "downloaded" }], structuredContent: { status: 200, - body: { - artifact: { - uri: "caplets://artifacts/http/call-1/report.pdf", - path: "/tmp/caplets/report.pdf", - filename: "report.pdf", - mimeType: "application/pdf", - byteLength: 12, - sha256: "a".repeat(64), - }, - }, + kind: "local-artifact", + uri: "caplets://artifacts/http/call-1/report.pdf", + path: "/tmp/caplets/report.pdf", + filename: "report.pdf", + mimeType: "application/pdf", + byteLength: 12, + sha256: "a".repeat(64), }, }), } as unknown as DownstreamManager; @@ -1016,12 +1060,47 @@ describe("generated tool handlers", () => { expect(result._meta.caplets.artifacts).toEqual([ { kind: "file", + presentation: "local-path", displayPath: "/tmp/caplets/report.pdf", pathResolution: "absolute", }, ]); }); + it("presents canonical remote references without filesystem metadata", async () => { + const downstream = { + callTool: vi.fn().mockResolvedValue({ + content: [{ type: "text" as const, text: "downloaded" }], + structuredContent: { + status: 200, + kind: "remote-reference", + uri: "caplets://artifacts/http/call-1/report.pdf", + filename: "report.pdf", + mimeType: "application/pdf", + byteLength: 12, + sha256: "a".repeat(64), + }, + }), + } as unknown as DownstreamManager; + + const result = await handleServerTool( + server, + { operation: "call_tool", name: "read", args: {} }, + registry, + downstream, + ); + + expect(result._meta.caplets.artifacts).toEqual([ + { + kind: "file", + presentation: "reference", + reference: "caplets://artifacts/http/call-1/report.pdf", + }, + ]); + expect(result._meta.caplets.artifacts[0]).not.toHaveProperty("displayPath"); + expect(result._meta.caplets.artifacts[0]).not.toHaveProperty("pathResolution"); + }); + it("extracts artifact links with spaces, title attributes, and parentheses", async () => { const result = await callToolWithText( 'Saved artifact [Screenshot](./screenshots/final view.png), file [Trace](./trace.zip "trace"), and artifact [Archive](./run(1).zip)', @@ -1030,16 +1109,19 @@ describe("generated tool handlers", () => { expect(result._meta.caplets.artifacts).toEqual([ { kind: "screenshot", + presentation: "local-path", displayPath: "./screenshots/final view.png", pathResolution: "relative-to-mcp-server", }, { kind: "file", + presentation: "local-path", displayPath: "./trace.zip", pathResolution: "relative-to-mcp-server", }, { kind: "file", + presentation: "local-path", displayPath: "./run(1).zip", pathResolution: "relative-to-mcp-server", }, @@ -1481,10 +1563,8 @@ describe("generated tool handlers", () => { structuredContent: { ok: true }, isError: false, }); - expect(result.content[0]?.text).toContain("# Graph call_tool query_user"); - expect(result.content[0]?.text).toContain("## Result"); - expect(result.content[0]?.text).toContain('"ok": true'); - expect({ ...result, content: graphqlResult.content }).toEqual({ + expect(result.content).toEqual(graphqlResult.content); + expect(result).toEqual({ ...graphqlResult, _meta: { caplets: { @@ -1581,10 +1661,8 @@ describe("generated tool handlers", () => { structuredContent: { ok: true }, isError: false, }); - expect(result.content[0]?.text).toContain("# Status HTTP call_tool check"); - expect(result.content[0]?.text).toContain("## Result"); - expect(result.content[0]?.text).toContain('"ok": true'); - expect({ ...result, content: httpResult.content }).toEqual({ + expect(result.content).toEqual(httpResult.content); + expect(result).toEqual({ ...httpResult, _meta: { caplets: { diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index 05c266a1..76429ffe 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -475,9 +475,10 @@ function createPiTool(service: NativeCapletsService, caplet: NativeCapletTool): const header = capletsResultHeader(metadata); const statusView = capletsStatusView(metadata.status); if (expanded) { - const artifactLines = metadata.artifacts.map( - (artifact) => - `Artifact: ${artifact.kind} ${artifact.displayPath} (${artifact.pathResolution})`, + const artifactLines = metadata.artifacts.map((artifact) => + artifact.presentation === "local-path" + ? `Artifact: ${artifact.kind} ${artifact.displayPath} (${artifact.pathResolution})` + : `Artifact: ${artifact.kind} ${artifact.reference}`, ); const output = resultFullContent(result.content); return textComponent( @@ -522,11 +523,18 @@ function fitLineToWidth(line: string, width: number): string { return truncateToWidth(line, width); } -type CapletsResultArtifact = { - kind: string; - displayPath: string; - pathResolution: string; -}; +type CapletsResultArtifact = + | { + kind: string; + presentation: "local-path"; + displayPath: string; + pathResolution: string; + } + | { + kind: string; + presentation: "reference"; + reference: string; + }; type CapletsResultMetadata = { name?: string; @@ -579,11 +587,19 @@ function capletsMetadata(details: unknown): CapletsResultMetadata | undefined { resultMetadata.artifacts = arrayProperty(metadata, "artifacts") .map((artifact) => { const kind = stringProperty(artifact, "kind"); - const displayPath = stringProperty(artifact, "displayPath"); - const pathResolution = stringProperty(artifact, "pathResolution"); - return kind && displayPath && pathResolution - ? { kind, displayPath, pathResolution } - : undefined; + const presentation = stringProperty(artifact, "presentation"); + if (presentation === "local-path") { + const displayPath = stringProperty(artifact, "displayPath"); + const pathResolution = stringProperty(artifact, "pathResolution"); + return kind && displayPath && pathResolution + ? { kind, presentation, displayPath, pathResolution } + : undefined; + } + if (presentation === "reference") { + const reference = stringProperty(artifact, "reference"); + return kind && reference ? { kind, presentation, reference } : undefined; + } + return undefined; }) .filter((artifact): artifact is CapletsResultArtifact => Boolean(artifact)); return resultMetadata; diff --git a/packages/pi/test/pi.test.ts b/packages/pi/test/pi.test.ts index 61eed2f2..899cd501 100644 --- a/packages/pi/test/pi.test.ts +++ b/packages/pi/test/pi.test.ts @@ -617,6 +617,7 @@ describe("@caplets/pi", () => { artifacts: [ { kind: "screenshot", + presentation: "local-path", displayPath: "./browser-caplet-localhost-4199.png", pathResolution: "relative-to-mcp-server", }, @@ -651,6 +652,53 @@ describe("@caplets/pi", () => { ); }); + it("renders remote artifact references without local-path affordances", async () => { + const service = mockService([ + { + caplet: "reports", + toolName: "caplets__reports", + title: "Reports", + description: "Reports Caplet", + promptGuidance: ["Use caplets__reports for Reports."], + }, + ]); + service.execute.mockResolvedValueOnce({ + content: [{ type: "text", text: "downloaded" }], + _meta: { + caplets: { + name: "Reports", + operation: "call_tool", + tool: "download", + artifacts: [ + { + kind: "file", + presentation: "reference", + reference: "caplets://artifacts/reports/call-1/report.pdf", + }, + ], + }, + }, + }); + const registered: RegisteredTool[] = []; + + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); + + const tool = registered[0]; + const result = await tool?.execute("call-1", { + operation: "call_tool", + tool: "download", + }); + const rendered = renderText( + tool?.renderResult(result!, { expanded: true, isPartial: false }, plainTheme), + ); + + expect(rendered).toContain("Artifact: file caplets://artifacts/reports/call-1/report.pdf"); + expect(rendered).not.toContain("relative-to-mcp-server"); + expect(rendered).not.toContain("(absolute)"); + }); + it("renders expanded caplet results with a metadata header before serialized output", async () => { const service = mockService([ { From b536fb498e5765b2c6b7c1ae6bd7883572c8e7ac Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 10 Jul 2026 02:00:00 -0400 Subject: [PATCH 2/9] refactor(core): centralize backend operation dispatch Construct one named runtime over the seven backend managers, route progressive, direct, completion-discovery, and nested calls through its six-operation interface, and retain MCP-only resource, prompt, and completion behavior on an explicit capability. --- .changeset/calm-media-seam.md | 2 + .../core/src/backend-operation-dispatch.ts | 156 ++++++++++ packages/core/src/caplet-sets.ts | 56 ++-- packages/core/src/engine.ts | 87 ++---- packages/core/src/index.ts | 19 +- packages/core/src/tools.ts | 292 +++++------------- .../test/backend-operation-dispatch.test.ts | 234 ++++++++++++++ .../core/test/backend-operation-runtime.ts | 28 ++ packages/core/test/caplet-sets.test.ts | 17 +- packages/core/test/cli-tools.test.ts | 8 +- packages/core/test/downstream.test.ts | 3 +- packages/core/test/google-discovery.test.ts | 24 +- packages/core/test/http-actions.test.ts | 16 +- packages/core/test/openapi.test.ts | 28 +- packages/core/test/tools.test.ts | 256 +++++++-------- 15 files changed, 715 insertions(+), 511 deletions(-) create mode 100644 packages/core/src/backend-operation-dispatch.ts create mode 100644 packages/core/test/backend-operation-dispatch.test.ts create mode 100644 packages/core/test/backend-operation-runtime.ts diff --git a/.changeset/calm-media-seam.md b/.changeset/calm-media-seam.md index eeac3bb5..76d879b6 100644 --- a/.changeset/calm-media-seam.md +++ b/.changeset/calm-media-seam.md @@ -6,3 +6,5 @@ Unify HTTP-like non-inline results behind explicit local-artifact and remote-reference variants, preserve mixed MCP content blocks, and prevent hosted Adapters from exposing managed filesystem paths. GraphQL operation results now share the Media pipeline with a 1 MiB inline threshold and a 100 MiB artifact cap. Pi renders local artifact paths and remote artifact references according to the result variant. + +Replace `handleServerTool`'s positional manager arguments with a named backend runtime. External `@caplets/core` callers now construct that runtime with `createBackendOperationRuntime`; common backend operations dispatch through its `operations` Interface, while MCP-only resource, prompt, and completion methods remain on `runtime.mcp`. diff --git a/packages/core/src/backend-operation-dispatch.ts b/packages/core/src/backend-operation-dispatch.ts new file mode 100644 index 00000000..4025ea30 --- /dev/null +++ b/packages/core/src/backend-operation-dispatch.ts @@ -0,0 +1,156 @@ +import type { + CompatibilityCallToolResult, + CompleteResult, + GetPromptResult, + ReadResourceResult, + Tool, +} from "@modelcontextprotocol/sdk/types"; +import type { + CapletConfig, + CapletServerConfig, + CapletSetConfig, + CliToolsConfig, + GoogleDiscoveryApiConfig, + GraphQlEndpointConfig, + HttpApiConfig, + OpenApiEndpointConfig, +} from "./config"; +import type { CompactTool, DownstreamManager } from "./downstream"; + +export type BackendCheckResult = { + id: string; + status: string; + toolCount?: number; + elapsedMs: number; + error?: unknown; +}; +export type BackendCallToolResult = + | CompatibilityCallToolResult + | ReadResourceResult + | GetPromptResult + | CompleteResult; + +export interface BackendOperationDispatch { + check(server: CapletConfig): Promise; + listTools(server: CapletConfig): Promise; + getTool(server: CapletConfig, toolName: string): Promise; + callTool( + server: CapletConfig, + toolName: string, + args: Record, + ): Promise; + compact(server: CapletConfig, tool: Tool): CompactTool; + search(server: CapletConfig, tools: Tool[], query: string, limit: number): CompactTool[]; +} + +export type McpOperationAdapter = Pick< + DownstreamManager, + | "listResources" + | "listResourceTemplates" + | "readResource" + | "listPrompts" + | "getPrompt" + | "complete" + | "compactResource" + | "compactResourceTemplate" + | "compactPrompt" + | "searchResources" + | "searchPrompts" +>; + +export type BackendOperationRuntime = { + operations: BackendOperationDispatch; + mcp: McpOperationAdapter; +}; + +export type BackendOperationManagers = { + mcp: ManagerWithCheck & McpOperationAdapter; + openapi: ManagerWithCheck; + googleDiscovery: ManagerWithCheck; + graphql: ManagerWithCheck; + http: ManagerWithCheck; + cli: ManagerWithCheck; + caplets: ManagerWithCheck; +}; + +export function createBackendOperationRuntime( + managers: BackendOperationManagers, +): BackendOperationRuntime { + return { + operations: createBackendOperationDispatch(managers), + mcp: managers.mcp, + }; +} + +type BackendOperationAdapter = { + manager: CommonManager; + check(server: C): Promise; +}; + +type BackendOperationAdapterTable = { + [B in CapletConfig["backend"]]: BackendOperationAdapter>; +}; + +export function createBackendOperationDispatch( + managers: BackendOperationManagers, +): BackendOperationDispatch { + const adapters: BackendOperationAdapterTable = { + mcp: { + manager: managers.mcp, + check: (server) => managers.mcp.checkServer(server), + }, + openapi: { + manager: managers.openapi, + check: (server) => managers.openapi.checkEndpoint(server), + }, + googleDiscovery: { + manager: managers.googleDiscovery, + check: (server) => managers.googleDiscovery.checkApi(server), + }, + graphql: { + manager: managers.graphql, + check: (server) => managers.graphql.checkEndpoint(server), + }, + http: { + manager: managers.http, + check: (server) => managers.http.checkApi(server), + }, + cli: { + manager: managers.cli, + check: (server) => managers.cli.checkTools(server), + }, + caplets: { + manager: managers.caplets, + check: (server) => managers.caplets.checkSet(server), + }, + }; + + const adapterFor = (server: C): BackendOperationAdapter => + adapters[server.backend] as BackendOperationAdapter; + + return { + check: (server) => adapterFor(server).check(server), + listTools: (server) => adapterFor(server).manager.listTools(server), + getTool: (server, toolName) => adapterFor(server).manager.getTool(server, toolName), + callTool: (server, toolName, args) => + adapterFor(server).manager.callTool(server, toolName, args), + compact: (server, tool) => adapterFor(server).manager.compact(server, tool), + search: (server, tools, query, limit) => + adapterFor(server).manager.search(server, tools, query, limit), + }; +} + +type CommonManager = { + listTools(server: C): Promise; + getTool(server: C, toolName: string): Promise; + callTool( + server: C, + toolName: string, + args: Record, + ): Promise; + compact(server: C, tool: Tool): CompactTool; + search(server: C, tools: Tool[], query: string, limit: number): CompactTool[]; +}; + +type ManagerWithCheck = CommonManager & + Record Promise>; diff --git a/packages/core/src/caplet-sets.ts b/packages/core/src/caplet-sets.ts index 78011488..ed2b55f8 100644 --- a/packages/core/src/caplet-sets.ts +++ b/packages/core/src/caplet-sets.ts @@ -1,5 +1,10 @@ import type { CompatibilityCallToolResult, Tool } from "@modelcontextprotocol/sdk/types"; import { resolve } from "node:path"; +import { + createBackendOperationRuntime, + type BackendCallToolResult, + type BackendOperationRuntime, +} from "./backend-operation-dispatch"; import { CliToolsManager } from "./cli-tools"; import { type CapletConfig, @@ -27,12 +32,8 @@ import { handleServerTool } from "./tools"; type ChildRuntime = { registry: ServerRegistry; downstream: DownstreamManager; - openapi: OpenApiManager; - graphql: GraphQLManager; - http: HttpActionManager; - cli: CliToolsManager; - googleDiscovery: GoogleDiscoveryManager; capletSets: CapletSetManager; + runtime: BackendOperationRuntime; cacheKey: string; configFingerprint: string; loadedAt: number; @@ -133,7 +134,7 @@ export class CapletSetManager { config: CapletSetConfig, toolName: string, args: Record, - ): Promise { + ): Promise { const child = await this.childRuntime(config, false); const caplet = child.registry.get(toolName); if (!caplet) { @@ -148,19 +149,7 @@ export class CapletSetManager { ); } try { - return (await handleServerTool( - caplet, - args, - child.registry, - child.downstream, - child.openapi, - child.graphql, - child.http, - child.cli, - child.capletSets, - {}, - child.googleDiscovery, - )) as CompatibilityCallToolResult; + return await handleServerTool(caplet, args, child.registry, child.runtime); } catch (error) { return errorResult(error) as CompatibilityCallToolResult; } @@ -242,17 +231,28 @@ export class CapletSetManager { : { mediaArtifactMaxBytes: this.options.mediaArtifactMaxBytes }), }; const childAncestry = new Set([...ancestry, cacheKey]); + const downstream = new DownstreamManager(registry, sharedOptions); + const openapi = new OpenApiManager(registry, sharedOptions); + const googleDiscovery = new GoogleDiscoveryManager(registry, sharedOptions); + const graphql = new GraphQLManager(registry, sharedOptions); + const http = new HttpActionManager(registry, sharedOptions); + const cli = new CliToolsManager(registry); + const capletSets = new CapletSetManager(registry, { + ...sharedOptions, + ancestry: childAncestry, + }); child = { registry, - downstream: new DownstreamManager(registry, sharedOptions), - openapi: new OpenApiManager(registry, sharedOptions), - graphql: new GraphQLManager(registry, sharedOptions), - http: new HttpActionManager(registry, sharedOptions), - cli: new CliToolsManager(registry), - googleDiscovery: new GoogleDiscoveryManager(registry, sharedOptions), - capletSets: new CapletSetManager(registry, { - ...sharedOptions, - ancestry: childAncestry, + downstream, + capletSets, + runtime: createBackendOperationRuntime({ + mcp: downstream, + openapi, + googleDiscovery, + graphql, + http, + cli, + caplets: capletSets, }), cacheKey, configFingerprint: JSON.stringify(config), diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index 101c6cab..d917f519 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -1,5 +1,9 @@ import { existsSync, readdirSync, statSync, watch, type FSWatcher } from "node:fs"; import { dirname, join, parse } from "node:path"; +import { + createBackendOperationRuntime, + type BackendOperationRuntime, +} from "./backend-operation-dispatch"; import { CapletSetManager } from "./caplet-sets"; import { CliToolsManager } from "./cli-tools"; import { findProjectRoot, fingerprintProjectRoot } from "./cloud/project-root"; @@ -105,6 +109,7 @@ export class CapletsEngine { private readonly http: HttpActionManager; private readonly cli: CliToolsManager; private readonly capletSets: CapletSetManager; + private readonly backendRuntime: BackendOperationRuntime; private readonly paths: RuntimePaths; private readonly watchDebounceMs: number; private readonly watchEnabled: boolean; @@ -163,6 +168,15 @@ export class CapletsEngine { projectBindingContext: options.projectBindingContext, }); this.capletSets = new CapletSetManager(this.registry, selectHttpLikeOptions(options)); + this.backendRuntime = createBackendOperationRuntime({ + mcp: this.downstream, + openapi: this.openapi, + googleDiscovery: this.googleDiscovery, + graphql: this.graphql, + http: this.http, + cli: this.cli, + caplets: this.capletSets, + }); this.watchDebounceMs = options.watchDebounceMs ?? 250; this.watchEnabled = options.watch ?? true; this.observedOutputShapeStore = @@ -198,7 +212,7 @@ export class CapletsEngine { ...(this.projectBindingContext === undefined ? {} : { projectBindingContext: this.projectBindingContext }), - listTools: async (caplet) => this.listTools(caplet), + listTools: async (caplet) => this.backendRuntime.operations.listTools(caplet), listResources: async (caplet) => this.optionalMcpList(caplet, () => this.downstream.listResources(caplet, true)), listResourceTemplates: async (caplet) => @@ -261,23 +275,11 @@ export class CapletsEngine { try { caplet = this.registry.require(serverId); this.assertProjectBindingCallable(caplet); - const result = await handleServerTool( - caplet, - request, - this.registry, - this.downstream, - this.openapi, - this.graphql, - this.http, - this.cli, - this.capletSets, - { - observedOutputShapeStore: this.observedOutputShapeStore, - observedOutputShapeScope: this.observedOutputShapeScope, - projectFingerprint: this.projectFingerprint, - }, - this.googleDiscovery, - ); + const result = await handleServerTool(caplet, request, this.registry, this.backendRuntime, { + observedOutputShapeStore: this.observedOutputShapeStore, + observedOutputShapeScope: this.observedOutputShapeScope, + projectFingerprint: this.projectFingerprint, + }); this.captureToolActivation( caplet, operationFromRequest(request), @@ -314,7 +316,7 @@ export class CapletsEngine { try { caplet = this.registry.require(serverId); this.assertProjectBindingCallable(caplet); - const result = await this.callTool(caplet, toolName, args); + const result = await this.backendRuntime.operations.callTool(caplet, toolName, args); const annotated = annotateDirectResult(result, caplet, toolName); this.captureToolActivation(caplet, "call_tool", "direct", annotated, started); return annotated; @@ -436,20 +438,7 @@ export class CapletsEngine { } private async listCompletionTools(server: CapletConfig): Promise { - const tools = - server.backend === "mcp" - ? await this.downstream.listTools(server) - : server.backend === "openapi" - ? await this.openapi.listTools(server) - : server.backend === "googleDiscovery" - ? await this.googleDiscovery.listTools(server) - : server.backend === "graphql" - ? await this.graphql.listTools(server) - : server.backend === "http" - ? await this.http.listTools(server) - : server.backend === "cli" - ? await this.cli.listTools(server) - : await this.capletSets.listTools(server); + const tools = await this.backendRuntime.operations.listTools(server); return tools.map((tool) => ({ name: tool.name, ...(tool.description ? { description: tool.description } : {}), @@ -487,38 +476,6 @@ export class CapletsEngine { ); } - private async listTools(server: CapletConfig) { - return server.backend === "mcp" - ? await this.downstream.listTools(server) - : server.backend === "openapi" - ? await this.openapi.listTools(server) - : server.backend === "googleDiscovery" - ? await this.googleDiscovery.listTools(server) - : server.backend === "graphql" - ? await this.graphql.listTools(server) - : server.backend === "http" - ? await this.http.listTools(server) - : server.backend === "cli" - ? await this.cli.listTools(server) - : await this.capletSets.listTools(server); - } - - private async callTool(server: CapletConfig, toolName: string, args: Record) { - return server.backend === "mcp" - ? await this.downstream.callTool(server, toolName, args) - : server.backend === "openapi" - ? await this.openapi.callTool(server, toolName, args) - : server.backend === "googleDiscovery" - ? await this.googleDiscovery.callTool(server, toolName, args) - : server.backend === "graphql" - ? await this.graphql.callTool(server, toolName, args) - : server.backend === "http" - ? await this.http.callTool(server, toolName, args) - : server.backend === "cli" - ? await this.cli.callTool(server, toolName, args) - : await this.capletSets.callTool(server, toolName, args); - } - private async optionalMcpList( caplet: Extract, list: () => Promise, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ff818b61..f9246910 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,9 +23,26 @@ export type { RuntimeRouteKind, SetupTargetKind as RuntimePlanSetupTargetKind, } from "./runtime-plan"; +export { + createBackendOperationDispatch, + createBackendOperationRuntime, +} from "./backend-operation-dispatch"; +export type { + BackendCallToolResult, + BackendCheckResult, + BackendOperationDispatch, + BackendOperationManagers, + BackendOperationRuntime, + McpOperationAdapter, +} from "./backend-operation-dispatch"; export { capabilityDescription, ServerRegistry } from "./registry"; export { generatedToolInputSchema, handleServerTool } from "./tools"; -export type { CapletArtifact, CapletExecutionMetadata, CapletResultMetadata } from "./tools"; +export type { + CapletArtifact, + CapletExecutionMetadata, + CapletResultMetadata, + HandleServerToolResult, +} from "./tools"; export { createCodeModeCapletsApi, listCodeModeCallableCaplets } from "./code-mode/api"; export type { CodeModeCapletHandle, diff --git a/packages/core/src/tools.ts b/packages/core/src/tools.ts index 30f3e6ab..cef65f16 100644 --- a/packages/core/src/tools.ts +++ b/packages/core/src/tools.ts @@ -1,14 +1,18 @@ -import type { CallToolResult } from "@modelcontextprotocol/sdk/types"; +import type { + CallToolResult, + CompleteResult, + GetPromptResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types"; import Ajv, { type ErrorObject, type ValidateFunction } from "ajv"; -import type { CapletSetManager } from "./caplet-sets"; +import type { + BackendCallToolResult, + BackendOperationDispatch, + BackendOperationRuntime, + McpOperationAdapter, +} from "./backend-operation-dispatch"; import type { CapletConfig } from "./config"; -import type { CliToolsManager } from "./cli-tools"; -import type { DownstreamManager } from "./downstream"; import { CapletsError } from "./errors"; -import type { GoogleDiscoveryManager } from "./google-discovery"; -import type { GraphQLManager } from "./graphql"; -import type { HttpActionManager } from "./http-actions"; -import type { OpenApiManager } from "./openapi"; import { normalizedObservableValue, observeOutputShape, @@ -43,6 +47,13 @@ const MAX_SCHEMA_ERRORS = 8; export type GeneratedServerToolRequest = RequiredOperationRequest; type ParsedOperationRequest = RequiredOperationRequest & Record; +type ReadResourceRequest = Extract; +type GetPromptRequest = Extract; +type CompleteRequest = Extract; +type CommonServerToolRequest = Exclude< + RequiredOperationRequest, + ReadResourceRequest | GetPromptRequest | CompleteRequest +>; export type HandleServerToolOptions = { observedOutputShapeStore?: ObservedOutputShapeStore | undefined; @@ -51,19 +62,51 @@ export type HandleServerToolOptions = { projectFingerprint?: string | undefined; }; +export type HandleServerToolResult = BackendCallToolResult; + +export function handleServerTool( + server: CapletConfig, + request: ReadResourceRequest, + registry: ServerRegistry, + runtime: BackendOperationRuntime, + options?: HandleServerToolOptions, +): Promise; +export function handleServerTool( + server: CapletConfig, + request: GetPromptRequest, + registry: ServerRegistry, + runtime: BackendOperationRuntime, + options?: HandleServerToolOptions, +): Promise; +export function handleServerTool( + server: CapletConfig, + request: CompleteRequest, + registry: ServerRegistry, + runtime: BackendOperationRuntime, + options?: HandleServerToolOptions, +): Promise; +export function handleServerTool( + server: CapletConfig, + request: CommonServerToolRequest, + registry: ServerRegistry, + runtime: BackendOperationRuntime, + options?: HandleServerToolOptions, +): Promise; +export function handleServerTool( + server: CapletConfig, + request: unknown, + registry: ServerRegistry, + runtime: BackendOperationRuntime, + options?: HandleServerToolOptions, +): Promise; + export async function handleServerTool( server: CapletConfig, request: unknown, registry: ServerRegistry, - downstream: DownstreamManager, - openapi?: OpenApiManager, - graphql?: GraphQLManager, - http?: HttpActionManager, - cli?: CliToolsManager, - caplets?: CapletSetManager, + runtime: BackendOperationRuntime, options: HandleServerToolOptions = {}, - googleDiscovery?: GoogleDiscoveryManager, -): Promise { +): Promise { const startedAt = Date.now(); const parsed = validateOperationRequest( request, @@ -78,32 +121,14 @@ export async function handleServerTool( metadataFor(server, "inspect", undefined, startedAt), ); case "check": { - const result = await backendFor( - server, - downstream, - openapi, - graphql, - http, - cli, - caplets, - googleDiscovery, - ).check(server as never); + const result = await runtime.operations.check(server); return jsonResult(result, metadataFor(server, "check", undefined, startedAt)); } case "tools": { - const backend = backendFor( - server, - downstream, - openapi, - graphql, - http, - cli, - caplets, - googleDiscovery, - ); - const tools = await backend.listTools(server as never); + const backend = runtime.operations; + const tools = await backend.listTools(server); const page = pageItems( - tools.map((tool) => backend.compact(server as never, tool)), + tools.map((tool) => backend.compact(server, tool)), parsed, registry.config.options.maxSearchLimit, ); @@ -117,19 +142,10 @@ export async function handleServerTool( ); } case "search_tools": { - const backend = backendFor( - server, - downstream, - openapi, - graphql, - http, - cli, - caplets, - googleDiscovery, - ); - const tools = await backend.listTools(server as never); + const backend = runtime.operations; + const tools = await backend.listTools(server); const limit = parsed.limit ?? registry.config.options.defaultSearchLimit; - const matches = backend.search(server as never, tools, parsed.query, limit); + const matches = backend.search(server, tools, parsed.query, limit); const page = pageItems(matches, parsed, registry.config.options.maxSearchLimit); return jsonResult( { @@ -142,17 +158,8 @@ export async function handleServerTool( ); } case "describe_tool": { - const backend = backendFor( - server, - downstream, - openapi, - graphql, - http, - cli, - caplets, - googleDiscovery, - ); - const tool = await backend.getTool(server as never, parsed.name); + const backend = runtime.operations; + const tool = await backend.getTool(server, parsed.name); const observedOutputShape = await readObservedOutputShape( options, server, @@ -170,20 +177,11 @@ export async function handleServerTool( ); } case "call_tool": { - const backend = backendFor( - server, - downstream, - openapi, - graphql, - http, - cli, - caplets, - googleDiscovery, - ); + const backend = runtime.operations; const tool = await maybeGetToolForValidation(backend, server, parsed.name); validateToolArgsForAgent(tool, parsed.name, parsed.args); if (parsed.fields === undefined) { - const result = await backend.callTool(server as never, parsed.name, parsed.args); + const result = await backend.callTool(server, parsed.name, parsed.args); await writeObservedOutputShape(options, server, parsed.name, result); return annotateCallToolResult( result, @@ -197,7 +195,7 @@ export async function handleServerTool( ); } - const fieldSelectionTool = tool ?? (await backend.getTool(server as never, parsed.name)); + const fieldSelectionTool = tool ?? (await backend.getTool(server, parsed.name)); if (!fieldSelectionTool.outputSchema) { throw new CapletsError( "REQUEST_INVALID", @@ -207,7 +205,7 @@ export async function handleServerTool( validateFieldSelection(fieldSelectionTool.outputSchema, parsed.fields); const metadata = metadataFor(server, "call_tool", parsed.name, startedAt); - const rawResult = await backend.callTool(server as never, parsed.name, parsed.args); + const rawResult = await backend.callTool(server, parsed.name, parsed.args); await writeObservedOutputShape(options, server, parsed.name, rawResult); const result = projectCallToolResult( rawResult, @@ -218,7 +216,7 @@ export async function handleServerTool( return annotateCallToolResult(result, metadata); } case "resources": { - const backend = mcpBackendFor(server, downstream, "page"); + const backend = mcpBackendFor(server, runtime.mcp, "page"); if (!backend) { return jsonResult( { id: server.server, name: server.name, items: [] }, @@ -241,7 +239,7 @@ export async function handleServerTool( ); } case "search_resources": { - const backend = mcpBackendFor(server, downstream, "page"); + const backend = mcpBackendFor(server, runtime.mcp, "page"); if (!backend) { return jsonResult( { id: server.server, name: server.name, query: parsed.query, items: [] }, @@ -268,7 +266,7 @@ export async function handleServerTool( ); } case "resource_templates": { - const backend = mcpBackendFor(server, downstream, "page"); + const backend = mcpBackendFor(server, runtime.mcp, "page"); if (!backend) { return jsonResult( { id: server.server, name: server.name, items: [] }, @@ -291,7 +289,7 @@ export async function handleServerTool( ); } case "read_resource": { - const result = await mcpBackendFor(server, downstream, "direct")!.readResource( + const result = await mcpBackendFor(server, runtime.mcp, "direct")!.readResource( server as never, parsed.uri, ); @@ -301,7 +299,7 @@ export async function handleServerTool( ); } case "prompts": { - const backend = mcpBackendFor(server, downstream, "page"); + const backend = mcpBackendFor(server, runtime.mcp, "page"); if (!backend) { return jsonResult( { id: server.server, name: server.name, items: [] }, @@ -324,7 +322,7 @@ export async function handleServerTool( ); } case "search_prompts": { - const backend = mcpBackendFor(server, downstream, "page"); + const backend = mcpBackendFor(server, runtime.mcp, "page"); if (!backend) { return jsonResult( { id: server.server, name: server.name, query: parsed.query, items: [] }, @@ -346,7 +344,7 @@ export async function handleServerTool( ); } case "get_prompt": { - const result = await mcpBackendFor(server, downstream, "direct")!.getPrompt( + const result = await mcpBackendFor(server, runtime.mcp, "direct")!.getPrompt( server as never, parsed.name, parsed.args, @@ -357,7 +355,7 @@ export async function handleServerTool( ); } case "complete": { - const result = await mcpBackendFor(server, downstream, "direct")!.complete(server as never, { + const result = await mcpBackendFor(server, runtime.mcp, "direct")!.complete(server as never, { ref: parsed.ref, argument: parsed.argument, }); @@ -380,13 +378,12 @@ function fieldSelectionFor( } async function maybeGetToolForValidation( - backend: unknown, + backend: BackendOperationDispatch, server: CapletConfig, toolName: string, ): Promise<{ inputSchema?: unknown; outputSchema?: unknown } | undefined> { - if (!hasGetTool(backend)) return undefined; try { - return await backend.getTool(server as never, toolName); + return await backend.getTool(server, toolName); } catch { return undefined; } @@ -645,17 +642,6 @@ function placeholderValueForSchema(schema: unknown, depth: number): unknown { return null; } -function hasGetTool(backend: unknown): backend is { - getTool(server: never, name: string): Promise<{ inputSchema?: unknown; outputSchema?: unknown }>; -} { - return Boolean( - backend && - typeof backend === "object" && - "getTool" in backend && - typeof (backend as { getTool?: unknown }).getTool === "function", - ); -} - function schemaToTypeScript(schema: unknown, fallbackName: string): string { return `type ${fallbackName} = ${schemaType(schema)};`; } @@ -921,9 +907,9 @@ function pageItems( function mcpBackendFor( server: CapletConfig, - downstream: DownstreamManager, + mcp: McpOperationAdapter, mode: "page" | "direct", -): DownstreamManager | undefined { +): McpOperationAdapter | undefined { if (server.backend !== "mcp") { if (mode === "page") return undefined; throw new CapletsError( @@ -931,7 +917,7 @@ function mcpBackendFor( "MCP resource, prompt, and completion operations require an MCP-backed Caplet", ); } - return downstream; + return mcp; } type RequiredOperationRequest = @@ -1364,111 +1350,3 @@ function artifactKindFromText(text: string): CapletArtifact["kind"] { function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } - -function backendFor( - server: CapletConfig | { backend: string }, - downstream: DownstreamManager, - openapi?: OpenApiManager, - graphql?: GraphQLManager, - http?: HttpActionManager, - cli?: CliToolsManager, - caplets?: CapletSetManager, - googleDiscovery?: GoogleDiscoveryManager, -) { - if (server.backend === "mcp") { - return { - check: (...args: Parameters) => - downstream.checkServer(...args), - listTools: (...args: Parameters) => - downstream.listTools(...args), - getTool: (...args: Parameters) => downstream.getTool(...args), - callTool: (...args: Parameters) => - downstream.callTool(...args), - compact: (...args: Parameters) => downstream.compact(...args), - search: (...args: Parameters) => downstream.search(...args), - }; - } - if (server.backend === "googleDiscovery") { - if (!googleDiscovery) { - throw new CapletsError("INTERNAL_ERROR", "Google Discovery manager is not configured"); - } - return { - check: (...args: Parameters) => - googleDiscovery.checkApi(...args), - listTools: (...args: Parameters) => - googleDiscovery.listTools(...args), - getTool: (...args: Parameters) => - googleDiscovery.getTool(...args), - callTool: (...args: Parameters) => - googleDiscovery.callTool(...args), - compact: (...args: Parameters) => - googleDiscovery.compact(...args), - search: (...args: Parameters) => - googleDiscovery.search(...args), - }; - } - if (server.backend === "graphql") { - if (!graphql) { - throw new CapletsError("INTERNAL_ERROR", "GraphQL manager is not configured"); - } - return { - check: (...args: Parameters) => - graphql.checkEndpoint(...args), - listTools: (...args: Parameters) => graphql.listTools(...args), - getTool: (...args: Parameters) => graphql.getTool(...args), - callTool: (...args: Parameters) => graphql.callTool(...args), - compact: (...args: Parameters) => graphql.compact(...args), - search: (...args: Parameters) => graphql.search(...args), - }; - } - if (server.backend === "http") { - if (!http) { - throw new CapletsError("INTERNAL_ERROR", "HTTP action manager is not configured"); - } - return { - check: (...args: Parameters) => http.checkApi(...args), - listTools: (...args: Parameters) => http.listTools(...args), - getTool: (...args: Parameters) => http.getTool(...args), - callTool: (...args: Parameters) => http.callTool(...args), - compact: (...args: Parameters) => http.compact(...args), - search: (...args: Parameters) => http.search(...args), - }; - } - if (server.backend === "cli") { - if (!cli) { - throw new CapletsError("INTERNAL_ERROR", "CLI tools manager is not configured"); - } - return { - check: (...args: Parameters) => cli.checkTools(...args), - listTools: (...args: Parameters) => cli.listTools(...args), - getTool: (...args: Parameters) => cli.getTool(...args), - callTool: (...args: Parameters) => cli.callTool(...args), - compact: (...args: Parameters) => cli.compact(...args), - search: (...args: Parameters) => cli.search(...args), - }; - } - if (server.backend === "caplets") { - if (!caplets) { - throw new CapletsError("INTERNAL_ERROR", "Caplet set manager is not configured"); - } - return { - check: (...args: Parameters) => caplets.checkSet(...args), - listTools: (...args: Parameters) => caplets.listTools(...args), - getTool: (...args: Parameters) => caplets.getTool(...args), - callTool: (...args: Parameters) => caplets.callTool(...args), - compact: (...args: Parameters) => caplets.compact(...args), - search: (...args: Parameters) => caplets.search(...args), - }; - } - if (!openapi) { - throw new CapletsError("INTERNAL_ERROR", "OpenAPI manager is not configured"); - } - return { - check: (...args: Parameters) => openapi.checkEndpoint(...args), - listTools: (...args: Parameters) => openapi.listTools(...args), - getTool: (...args: Parameters) => openapi.getTool(...args), - callTool: (...args: Parameters) => openapi.callTool(...args), - compact: (...args: Parameters) => openapi.compact(...args), - search: (...args: Parameters) => openapi.search(...args), - }; -} diff --git a/packages/core/test/backend-operation-dispatch.test.ts b/packages/core/test/backend-operation-dispatch.test.ts new file mode 100644 index 00000000..4b7a7fb8 --- /dev/null +++ b/packages/core/test/backend-operation-dispatch.test.ts @@ -0,0 +1,234 @@ +import { fileURLToPath } from "node:url"; +import type { Tool } from "@modelcontextprotocol/sdk/types"; +import { describe, expect, it, vi } from "vitest"; +import { + createBackendOperationRuntime, + handleServerTool, + parseConfig, + ServerRegistry, + type BackendOperationDispatch, + type BackendOperationManagers, + type McpOperationAdapter, +} from "../src/index"; +import { DownstreamManager } from "../src/downstream"; +import type { CapletConfig } from "../src/config"; +import { testBackendOperationRuntime } from "./backend-operation-runtime"; + +const BACKENDS = [ + ["mcp", "mcp", "checkServer"], + ["openapi", "openapi", "checkEndpoint"], + ["googleDiscovery", "googleDiscovery", "checkApi"], + ["graphql", "graphql", "checkEndpoint"], + ["http", "http", "checkApi"], + ["cli", "cli", "checkTools"], + ["caplets", "caplets", "checkSet"], +] as const; + +const TOOL: Tool = { + name: "echo", + inputSchema: { type: "object" }, +}; + +describe("backend operation dispatch", () => { + it.each(BACKENDS)( + "routes all common %s operations to only the selected adapter", + async (backend, managerName, checkMethod) => { + const managers = managerBundle(); + const runtime = createBackendOperationRuntime( + managers as unknown as BackendOperationManagers, + ); + const server = { backend, server: `${backend}-server` } as CapletConfig; + const selected = managers[managerName]; + + await expect(runtime.operations.check(server)).resolves.toEqual({ source: managerName }); + await expect(runtime.operations.listTools(server)).resolves.toEqual([TOOL]); + await expect(runtime.operations.getTool(server, "echo")).resolves.toBe(TOOL); + await expect(runtime.operations.callTool(server, "echo", { value: 1 })).resolves.toEqual({ + source: managerName, + }); + expect(runtime.operations.compact(server, TOOL)).toEqual({ name: "echo" }); + expect(runtime.operations.search(server, [TOOL], "ech", 5)).toEqual([{ name: "echo" }]); + + expect(selected[checkMethod]).toHaveBeenCalledOnce(); + expect(selected.listTools).toHaveBeenCalledOnce(); + expect(selected.getTool).toHaveBeenCalledOnce(); + expect(selected.callTool).toHaveBeenCalledOnce(); + expect(selected.compact).toHaveBeenCalledOnce(); + expect(selected.search).toHaveBeenCalledOnce(); + + for (const [otherBackend, otherManagerName, otherCheckMethod] of BACKENDS) { + if (otherBackend === backend) continue; + const other = managers[otherManagerName]; + expect(other[otherCheckMethod]).not.toHaveBeenCalled(); + expect(other.listTools).not.toHaveBeenCalled(); + expect(other.getTool).not.toHaveBeenCalled(); + expect(other.callTool).not.toHaveBeenCalled(); + expect(other.compact).not.toHaveBeenCalled(); + expect(other.search).not.toHaveBeenCalled(); + } + }, + ); + + it("retains the exact MCP manager as the separately named MCP capability", () => { + const managers = managerBundle(); + const runtime = createBackendOperationRuntime(managers as unknown as BackendOperationManagers); + + expect(runtime.mcp).toBe(managers.mcp); + }); + + it("keeps inspect local and non-MCP discovery out of both capabilities", async () => { + const config = parseConfig({ + httpApis: { + status: { + name: "Status", + description: "Read service status.", + baseUrl: "https://api.example.com", + auth: { type: "none" }, + actions: { read: { method: "GET", path: "/status" } }, + }, + }, + }); + const registry = new ServerRegistry(config); + const operations = { + check: vi.fn(), + listTools: vi.fn(), + getTool: vi.fn(), + callTool: vi.fn(), + compact: vi.fn(), + search: vi.fn(), + } as unknown as BackendOperationDispatch; + const mcp = { + listResources: vi.fn(), + } as unknown as McpOperationAdapter; + const runtime = { operations, mcp }; + const server = config.httpApis.status!; + + const inspected = await handleServerTool(server, { operation: "inspect" }, registry, runtime); + await expect( + handleServerTool(server, { operation: "resources" }, registry, runtime), + ).rejects.toMatchObject({ code: "UNSUPPORTED_OPERATION" }); + + expect(inspected.structuredContent).toMatchObject({ + result: { id: "status", backend: { type: "http" } }, + }); + expect(operations.check).not.toHaveBeenCalled(); + expect(operations.listTools).not.toHaveBeenCalled(); + expect(mcp.listResources).not.toHaveBeenCalled(); + }); + + it("passes a selected adapter failure through unchanged", async () => { + const failure = new Error("opaque adapter failure"); + const managers = managerBundle(); + managers.http.callTool.mockRejectedValueOnce(failure); + const runtime = createBackendOperationRuntime(managers as unknown as BackendOperationManagers); + const server = { backend: "http", server: "status" } as CapletConfig; + + await expect(runtime.operations.callTool(server, "read", {})).rejects.toBe(failure); + }); + + it("preserves real MCP-only operations through the named runtime capability", async () => { + const fixture = fileURLToPath(new URL("fixtures/stdio-server.ts", import.meta.url)); + const config = parseConfig({ + mcpServers: { + fixture: { + name: "Fixture", + description: "A useful fixture server.", + command: process.execPath, + args: ["--import", import.meta.resolve("tsx"), fixture], + }, + }, + }); + const registry = new ServerRegistry(config); + const mcp = new DownstreamManager(registry); + const runtime = testBackendOperationRuntime(registry, { mcp }); + const server = config.mcpServers.fixture!; + + try { + const resources = await handleServerTool( + server, + { operation: "resources" }, + registry, + runtime, + ); + const templates = await handleServerTool( + server, + { operation: "resource_templates" }, + registry, + runtime, + ); + const resource = await handleServerTool( + server, + { operation: "read_resource", uri: "file:///repo/README.md" }, + registry, + runtime, + ); + const prompts = await handleServerTool(server, { operation: "prompts" }, registry, runtime); + const prompt = await handleServerTool( + server, + { operation: "get_prompt", name: "review_issue", args: { issueId: "CAP-123" } }, + registry, + runtime, + ); + const completion = await handleServerTool( + server, + { + operation: "complete", + ref: { type: "resourceTemplate", uri: "file:///repo/{path}" }, + argument: { name: "path", value: "READ" }, + }, + registry, + runtime, + ); + + expect(resources.structuredContent).toMatchObject({ + result: { + items: expect.arrayContaining([ + expect.objectContaining({ uri: "file:///repo/README.md" }), + ]), + }, + }); + expect(templates.structuredContent).toMatchObject({ + result: { items: [{ uriTemplate: "file:///repo/{path}" }] }, + }); + expect(resource.contents).toEqual([ + { + uri: "file:///repo/README.md", + text: "# Fixture README", + mimeType: "text/markdown", + }, + ]); + expect(prompts.structuredContent).toMatchObject({ + result: { items: [{ prompt: "review_issue" }] }, + }); + expect(prompt.messages).toEqual([ + { role: "user", content: { type: "text", text: "Review CAP-123" } }, + ]); + expect(completion.completion.values).toEqual(["README.md"]); + } finally { + await mcp.close(); + } + }); +}); + +function managerBundle() { + return { + mcp: manager("mcp", "checkServer"), + openapi: manager("openapi", "checkEndpoint"), + googleDiscovery: manager("googleDiscovery", "checkApi"), + graphql: manager("graphql", "checkEndpoint"), + http: manager("http", "checkApi"), + cli: manager("cli", "checkTools"), + caplets: manager("caplets", "checkSet"), + }; +} + +function manager(source: string, checkMethod: string) { + return { + [checkMethod]: vi.fn(async () => ({ source })), + listTools: vi.fn(async () => [TOOL]), + getTool: vi.fn(async () => TOOL), + callTool: vi.fn(async () => ({ source })), + compact: vi.fn(() => ({ name: TOOL.name })), + search: vi.fn(() => [{ name: TOOL.name }]), + }; +} diff --git a/packages/core/test/backend-operation-runtime.ts b/packages/core/test/backend-operation-runtime.ts new file mode 100644 index 00000000..60ac7bb2 --- /dev/null +++ b/packages/core/test/backend-operation-runtime.ts @@ -0,0 +1,28 @@ +import { + createBackendOperationRuntime, + type BackendOperationManagers, + type BackendOperationRuntime, +} from "../src/backend-operation-dispatch"; +import { CapletSetManager } from "../src/caplet-sets"; +import { CliToolsManager } from "../src/cli-tools"; +import { DownstreamManager } from "../src/downstream"; +import { GoogleDiscoveryManager } from "../src/google-discovery"; +import { GraphQLManager } from "../src/graphql"; +import { HttpActionManager } from "../src/http-actions"; +import { OpenApiManager } from "../src/openapi"; +import type { ServerRegistry } from "../src/registry"; + +export function testBackendOperationRuntime( + registry: ServerRegistry, + overrides: Partial = {}, +): BackendOperationRuntime { + return createBackendOperationRuntime({ + mcp: overrides.mcp ?? new DownstreamManager(registry), + openapi: overrides.openapi ?? new OpenApiManager(registry), + googleDiscovery: overrides.googleDiscovery ?? new GoogleDiscoveryManager(registry), + graphql: overrides.graphql ?? new GraphQLManager(registry), + http: overrides.http ?? new HttpActionManager(registry), + cli: overrides.cli ?? new CliToolsManager(registry), + caplets: overrides.caplets ?? new CapletSetManager(registry), + }); +} diff --git a/packages/core/test/caplet-sets.test.ts b/packages/core/test/caplet-sets.test.ts index 73025eb9..b5b553d4 100644 --- a/packages/core/test/caplet-sets.test.ts +++ b/packages/core/test/caplet-sets.test.ts @@ -10,6 +10,7 @@ import { DownstreamManager } from "../src/downstream"; import { ServerRegistry } from "../src/registry"; import { FileVaultStore } from "../src/vault"; import { handleServerTool } from "../src/tools"; +import { testBackendOperationRuntime } from "./backend-operation-runtime"; describe("CapletSetManager", () => { const dirs: string[] = []; @@ -115,12 +116,7 @@ describe("CapletSetManager", () => { args: { operation: "call_tool", name: "mixed", args: {} }, }, registry, - downstream, - undefined, - undefined, - undefined, - undefined, - manager, + testBackendOperationRuntime(registry, { mcp: downstream, caplets: manager }), ); expect(result.content).toEqual([ @@ -272,8 +268,7 @@ describe("CapletSetManager", () => { }, }); const caplet = config.capletSets.nested!; - const artifactDir = join(dir, "artifacts"); - const manager = new CapletSetManager(new ServerRegistry(config), { artifactDir }); + const manager = new CapletSetManager(new ServerRegistry(config)); const result = await manager.callTool(caplet, "drive", { operation: "tools" }); @@ -284,12 +279,6 @@ describe("CapletSetManager", () => { items: [{ name: "drive.files.list" }], }, }); - const child = ( - manager as unknown as { - children: Map; - } - ).children.get("nested"); - expect(child?.googleDiscovery.options.artifactDir).toBe(artifactDir); }); it("propagates Media thresholds, artifact roots, and hidden paths to nested HTTP Actions", async () => { diff --git a/packages/core/test/cli-tools.test.ts b/packages/core/test/cli-tools.test.ts index 4b9c80af..987e53d1 100644 --- a/packages/core/test/cli-tools.test.ts +++ b/packages/core/test/cli-tools.test.ts @@ -8,6 +8,7 @@ import { CapletsError } from "../src/errors"; import { ServerRegistry } from "../src/registry"; import { handleServerTool } from "../src/tools"; import { DownstreamManager } from "../src/downstream"; +import { testBackendOperationRuntime } from "./backend-operation-runtime"; describe("CliToolsManager", () => { const dirs: string[] = []; @@ -231,6 +232,7 @@ describe("CliToolsManager", () => { const registry = new ServerRegistry(config); const manager = new CliToolsManager(registry); + const downstream = new DownstreamManager(registry); const result = await handleServerTool( caplet, { @@ -240,11 +242,7 @@ describe("CliToolsManager", () => { fields: ["json.message"], }, registry, - new DownstreamManager(registry), - undefined, - undefined, - undefined, - manager, + testBackendOperationRuntime(registry, { mcp: downstream, cli: manager }), ); expect(result.structuredContent).toEqual({ json: { message: "hello" } }); diff --git a/packages/core/test/downstream.test.ts b/packages/core/test/downstream.test.ts index 1fce3765..e0957b75 100644 --- a/packages/core/test/downstream.test.ts +++ b/packages/core/test/downstream.test.ts @@ -11,6 +11,7 @@ import type { CapletsError } from "../src/errors"; import { writeTokenBundle } from "../src/auth"; import { handleServerTool } from "../src/tools"; import type { Tool } from "@modelcontextprotocol/sdk/types"; +import { testBackendOperationRuntime } from "./backend-operation-runtime"; const fixturesDir = fileURLToPath(new URL("fixtures", import.meta.url)); const tsxImport = import.meta.resolve("tsx"); @@ -224,7 +225,7 @@ describe("downstream stdio lifecycle", () => { fields: ["message"], }, registry, - manager, + testBackendOperationRuntime(registry, { mcp: manager }), )) as any; expect(result).toMatchObject({ diff --git a/packages/core/test/google-discovery.test.ts b/packages/core/test/google-discovery.test.ts index f8e49a88..f9a67c26 100644 --- a/packages/core/test/google-discovery.test.ts +++ b/packages/core/test/google-discovery.test.ts @@ -15,6 +15,7 @@ import { parseConfig } from "../src/config"; import { DownstreamManager } from "../src/downstream"; import { ServerRegistry } from "../src/registry"; import { handleServerTool } from "../src/tools"; +import { testBackendOperationRuntime } from "./backend-operation-runtime"; const fixture = JSON.parse( readFileSync(join(__dirname, "fixtures/google-discovery/drive.discovery.json"), "utf8"), @@ -1156,19 +1157,14 @@ describe("GoogleDiscoveryManager", () => { caplet, { operation: "tools" }, registry, - downstream, - undefined, - undefined, - undefined, - undefined, - undefined, + testBackendOperationRuntime(registry, { mcp: downstream, googleDiscovery: manager }), {}, - manager, ); + const listed = list.structuredContent as unknown as { + result: { items: Array<{ name: string }> }; + }; - expect( - list.structuredContent.result.items.map((tool: { name: string }) => tool.name), - ).toContain("drive.files.list"); + expect(listed.result.items.map((tool) => tool.name)).toContain("drive.files.list"); const call = await handleServerTool( caplet, @@ -1179,14 +1175,8 @@ describe("GoogleDiscoveryManager", () => { fields: ["body.files"], }, registry, - downstream, - undefined, - undefined, - undefined, - undefined, - undefined, + testBackendOperationRuntime(registry, { mcp: downstream, googleDiscovery: manager }), {}, - manager, ); expect(call.structuredContent).toEqual({ body: { files: [{ id: "1", name: "Report" }] } }); diff --git a/packages/core/test/http-actions.test.ts b/packages/core/test/http-actions.test.ts index ea1957e7..3f3034b2 100644 --- a/packages/core/test/http-actions.test.ts +++ b/packages/core/test/http-actions.test.ts @@ -9,6 +9,7 @@ import { HttpActionManager } from "../src/http-actions"; import { ServerRegistry } from "../src/registry"; import { CapletsEngine } from "../src/engine"; import { handleServerTool } from "../src/tools"; +import { testBackendOperationRuntime } from "./backend-operation-runtime"; describe("HttpActionManager", () => { let baseUrl = ""; @@ -223,10 +224,7 @@ describe("HttpActionManager", () => { caplet, { operation: "describe_tool", name: "ping" }, registry, - downstream, - undefined, - undefined, - http, + testBackendOperationRuntime(registry, { mcp: downstream, http }), )) as any; expect(fetched.structuredContent.result.tool.outputSchema).toMatchObject({ properties: { body: { properties: { ok: { type: "boolean" } } } }, @@ -236,10 +234,7 @@ describe("HttpActionManager", () => { caplet, { operation: "call_tool", name: "ping", args: {}, fields: ["body.ok"] }, registry, - downstream, - undefined, - undefined, - http, + testBackendOperationRuntime(registry, { mcp: downstream, http }), )) as any; expect(projected.structuredContent).toEqual({ body: { ok: true } }); expect(projected.content[0].text).toContain("# HTTP API call_tool ping"); @@ -380,10 +375,7 @@ describe("HttpActionManager", () => { config.httpApis.http!, { operation: "call_tool", name: "pdf", args: {} }, registry, - downstream, - undefined, - undefined, - http, + testBackendOperationRuntime(registry, { mcp: downstream, http }), ); expect(localArtifactPath(result)).toContain(artifactDir); diff --git a/packages/core/test/openapi.test.ts b/packages/core/test/openapi.test.ts index 6088d790..956642e6 100644 --- a/packages/core/test/openapi.test.ts +++ b/packages/core/test/openapi.test.ts @@ -9,6 +9,7 @@ import { DownstreamManager } from "../src/downstream"; import { OpenApiManager } from "../src/openapi"; import { handleServerTool } from "../src/tools"; import { writeTokenBundle } from "../src/auth"; +import { testBackendOperationRuntime } from "./backend-operation-runtime"; describe("native OpenAPI Caplets", () => { let baseUrl = ""; @@ -205,8 +206,7 @@ describe("native OpenAPI Caplets", () => { caplet, { operation: "tools" }, registry, - downstream, - openapi, + testBackendOperationRuntime(registry, { mcp: downstream, openapi }), )) as any; expect( list.structuredContent.result.items.map((tool: { name: string }) => tool.name), @@ -225,8 +225,7 @@ describe("native OpenAPI Caplets", () => { caplet, { operation: "describe_tool", name: "GET /users/{id}" }, registry, - downstream, - openapi, + testBackendOperationRuntime(registry, { mcp: downstream, openapi }), )) as any; expect(tool.structuredContent.result.tool.inputSchema).toMatchObject({ type: "object", @@ -275,8 +274,7 @@ describe("native OpenAPI Caplets", () => { args: { path: { id: "42" }, query: { active: true } }, }, registry, - downstream, - openapi, + testBackendOperationRuntime(registry, { mcp: downstream, openapi }), )) as any; expect(result.structuredContent).toMatchObject({ status: 200, @@ -292,8 +290,7 @@ describe("native OpenAPI Caplets", () => { fields: ["body.name"], }, registry, - downstream, - openapi, + testBackendOperationRuntime(registry, { mcp: downstream, openapi }), )) as any; expect(projected.structuredContent).toEqual({ body: { name: "Ada" } }); @@ -305,8 +302,7 @@ describe("native OpenAPI Caplets", () => { args: { body: { name: "Ada" } }, }, registry, - downstream, - openapi, + testBackendOperationRuntime(registry, { mcp: downstream, openapi }), )) as any; expect(create.structuredContent).toMatchObject({ status: 201, @@ -322,8 +318,7 @@ describe("native OpenAPI Caplets", () => { fields: ["body.created"], }, registry, - downstream, - openapi, + testBackendOperationRuntime(registry, { mcp: downstream, openapi }), ), ).rejects.toMatchObject({ code: "REQUEST_INVALID" }); expect(requests.some((request) => request.headers["x-api-key"] === "secret-key")).toBe(true); @@ -678,8 +673,7 @@ describe("native OpenAPI Caplets", () => { caplet, { operation: "describe_tool", name: "schemaLess" }, registry, - downstream, - openapi, + testBackendOperationRuntime(registry, { mcp: downstream, openapi }), )) as any; expect(tool.structuredContent.result.tool.outputSchema).toBeUndefined(); @@ -689,8 +683,7 @@ describe("native OpenAPI Caplets", () => { caplet, { operation: "call_tool", name: "schemaLess", args: {}, fields: ["body"] }, registry, - downstream, - openapi, + testBackendOperationRuntime(registry, { mcp: downstream, openapi }), ), ).rejects.toMatchObject({ code: "REQUEST_INVALID" }); expect(requests.some((request) => request.url === "/schema-less")).toBe(false); @@ -1076,8 +1069,7 @@ describe("native OpenAPI Caplets", () => { fields: ["body.name"], }, registry, - downstream, - openapi, + testBackendOperationRuntime(registry, { mcp: downstream, openapi }), ), ).rejects.toMatchObject({ code: "REQUEST_INVALID" }); } finally { diff --git a/packages/core/test/tools.test.ts b/packages/core/test/tools.test.ts index dc26d90a..3d4531df 100644 --- a/packages/core/test/tools.test.ts +++ b/packages/core/test/tools.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import type { Tool } from "@modelcontextprotocol/sdk/types"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types"; import { z } from "zod"; import { parseConfig } from "../src/config"; +import type { BackendOperationDispatch } from "../src/backend-operation-dispatch"; +import { testBackendOperationRuntime } from "./backend-operation-runtime"; import { DownstreamManager } from "../src/downstream"; import { CapletsError } from "../src/errors"; import type { GraphQLManager, GraphqlEndpointConfig } from "../src/graphql"; @@ -19,6 +21,7 @@ import { projectCallToolResult, validateOperationRequest, } from "../src/tools"; +import type { CapletResultMetadata } from "../src/tools"; describe("generated tool request validation", () => { it("rejects operation-specific extra fields", () => { @@ -192,7 +195,7 @@ describe("generated tool handlers", () => { server, { operation: "inspect" }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), )) as any; expect(result.structuredContent?.caplets).toEqual({ id: "alpha", @@ -237,7 +240,7 @@ describe("generated tool handlers", () => { openApiConfig.openapiEndpoints.users!, { operation: "inspect" }, openApiRegistry, - downstream, + testBackendOperationRuntime(openApiRegistry, { mcp: downstream }), )) as any; expect(result.structuredContent?.result).toMatchObject({ @@ -249,33 +252,6 @@ describe("generated tool handlers", () => { }); }); - it("fails explicitly for Google Discovery tools until the manager is configured", async () => { - const config = parseConfig({ - googleDiscoveryApis: { - drive: { - name: "Google Drive", - description: "Access Google Drive files and permissions.", - discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest", - auth: { type: "none" }, - }, - }, - }); - const googleRegistry = new ServerRegistry(config); - const downstream = {} as unknown as DownstreamManager; - - await expect( - handleServerTool( - config.googleDiscoveryApis.drive!, - { operation: "tools" }, - googleRegistry, - downstream, - ), - ).rejects.toMatchObject({ - code: "INTERNAL_ERROR", - message: "Google Discovery manager is not configured", - }); - }); - it("returns HTTP inspect without requiring an HTTP manager", async () => { const httpConfig = parseConfig({ httpApis: { @@ -295,7 +271,7 @@ describe("generated tool handlers", () => { httpConfig.httpApis.status!, { operation: "inspect" }, httpRegistry, - downstream, + testBackendOperationRuntime(httpRegistry, { mcp: downstream }), )) as any; expect(result.structuredContent?.result).toMatchObject({ @@ -314,15 +290,16 @@ describe("generated tool handlers", () => { listTools: vi.fn(), callTool: vi.fn(), } as unknown as DownstreamManager; - const result = (await handleServerTool( - server, - { operation: "check" }, - registry, - downstream, - )) as any; + const operations = { + check: vi.fn().mockResolvedValue(status), + } as unknown as BackendOperationDispatch; + const result = (await handleServerTool(server, { operation: "check" }, registry, { + operations, + mcp: downstream, + })) as unknown as { structuredContent?: { result?: unknown } }; expect(result.structuredContent?.result).toEqual(status); - expect(downstream.checkServer).toHaveBeenCalledWith(server); + expect(operations.check).toHaveBeenCalledWith(server); expect(downstream.listTools).not.toHaveBeenCalled(); expect(downstream.callTool).not.toHaveBeenCalled(); }); @@ -344,19 +321,19 @@ describe("generated tool handlers", () => { browserConfig.mcpServers.browser!, { operation: "tools" }, browserRegistry, - downstream, + testBackendOperationRuntime(browserRegistry, { mcp: downstream }), )) as any; const stealth = (await handleServerTool( browserConfig.mcpServers.stealth!, { operation: "tools" }, browserRegistry, - downstream, + testBackendOperationRuntime(browserRegistry, { mcp: downstream }), )) as any; - expect(browser.content[0]?.text).toContain("browser_click"); - expect(stealth.content[0]?.text).toContain("browser_click"); - expect(browser.content[0]?.text).toContain("Browser"); - expect(stealth.content[0]?.text).toContain("Stealth Browser"); + expect(textContent(browser, 0)).toContain("browser_click"); + expect(textContent(stealth, 0)).toContain("browser_click"); + expect(textContent(browser, 0)).toContain("Browser"); + expect(textContent(stealth, 0)).toContain("Stealth Browser"); expect(browser.structuredContent?.result.items).toEqual([{ name: "browser_click" }]); expect(stealth.structuredContent?.result.items).toEqual([{ name: "browser_click" }]); }); @@ -378,9 +355,9 @@ describe("generated tool handlers", () => { server, { operation: "tools" }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), )) as any; - const text = list.content[0]?.text ?? ""; + const text = textContent(list, 0) ?? ""; expect(text).toContain("1. `search` — Search records by query."); expect(text).toContain("2. `list` — List recent records."); @@ -425,14 +402,14 @@ describe("generated tool handlers", () => { server, { operation: "tools" }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), )) as any; - expect(list.content[0]?.text).toContain("read"); - expect(list.content[0]?.text).toContain("supports fields"); - expect(list.content[0]?.text).toContain('args template: {"path":""}'); - expect(list.content[0]?.text).toContain("read-only"); - expect(list.content[0]?.text).toContain("structuredContent.result"); - expect(list.content[0]?.text).not.toContain("## Full Result"); + expect(textContent(list, 0)).toContain("read"); + expect(textContent(list, 0)).toContain("supports fields"); + expect(textContent(list, 0)).toContain('args template: {"path":""}'); + expect(textContent(list, 0)).toContain("read-only"); + expect(textContent(list, 0)).toContain("structuredContent.result"); + expect(textContent(list, 0)).not.toContain("## Full Result"); expect(list.structuredContent?.caplets).toEqual({ id: "alpha", name: "Alpha", @@ -473,7 +450,7 @@ describe("generated tool handlers", () => { server, { operation: "describe_tool", name: "write" }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), )) as any; expect(full.structuredContent?.caplets).toEqual({ id: "alpha", @@ -501,7 +478,7 @@ describe("generated tool handlers", () => { server, { operation: "tools", limit: 1 }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), )) as any; expect(list.structuredContent?.result.items).toEqual([{ name: "read" }]); @@ -606,24 +583,14 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "write", args: {} }, registry, - downstream, - undefined, - undefined, - undefined, - undefined, - undefined, + testBackendOperationRuntime(registry, { mcp: downstream }), { observedOutputShapeStore: store, projectFingerprint: "project-a" }, ); const described = (await handleServerTool( server, { operation: "describe_tool", name: "write" }, registry, - downstream, - undefined, - undefined, - undefined, - undefined, - undefined, + testBackendOperationRuntime(registry, { mcp: downstream }), { observedOutputShapeStore: store, projectFingerprint: "project-a" }, )) as any; @@ -657,24 +624,14 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "read", args: {} }, registry, - downstream, - undefined, - undefined, - undefined, - undefined, - undefined, + testBackendOperationRuntime(registry, { mcp: downstream }), { observedOutputShapeStore: store }, ); const described = (await handleServerTool( server, { operation: "describe_tool", name: "read" }, registry, - downstream, - undefined, - undefined, - undefined, - undefined, - undefined, + testBackendOperationRuntime(registry, { mcp: downstream }), { observedOutputShapeStore: store }, )) as any; @@ -700,17 +657,12 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "write", args: {} }, registry, - downstream, - undefined, - undefined, - undefined, - undefined, - undefined, + testBackendOperationRuntime(registry, { mcp: downstream }), { observedOutputShapeStore: store }, )) as any; expect(result.structuredContent).toEqual({ items: [{ id: 1 }] }); - expect(result._meta.caplets.status).toBe("ok"); + expect(capletsMetadata(result).status).toBe("ok"); }); it("returns descriptor-driven guidance for wrong call_tool argument names", async () => { @@ -735,7 +687,7 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "search_issues", args: { q: "repo:o/r", per_page: 10 } }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ), ).rejects.toMatchObject({ code: "REQUEST_INVALID", @@ -791,7 +743,7 @@ describe("generated tool handlers", () => { }, }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ), ).rejects.toMatchObject({ code: "REQUEST_INVALID", @@ -831,13 +783,13 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "read", args: { path: "x" } }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ); expect(result).not.toBe(downstreamResult); expect(downstreamResult).toEqual(originalDownstreamResult); - expect(result.content[0]?.text).toContain("ok"); + expect(textContent(result, 0)).toContain("ok"); expect(result.content).toHaveLength(1); - expect(result.content[0]?.text).not.toContain("## Structured Content"); + expect(textContent(result, 0)).not.toContain("## Structured Content"); expect({ ...result, content: originalDownstreamResult.content }).toEqual({ ...originalDownstreamResult, _meta: { @@ -867,11 +819,11 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "write", args: {} }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ); - expect(result.content[0]?.text).toContain("ok"); + expect(textContent(result, 0)).toContain("ok"); expect(result.content).toHaveLength(1); - expect(result.content[0]?.text).not.toContain("## Structured Content"); + expect(textContent(result, 0)).not.toContain("## Structured Content"); expect({ ...result, content: downstreamResult.content }).toEqual({ ...downstreamResult, _meta: { @@ -909,7 +861,7 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "read", args: {} }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ); expect(result).toEqual({ @@ -945,11 +897,11 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "read", args: { path: "x" } }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ); - expect(result.content[0]?.text).toContain("failed"); + expect(textContent(result, 0)).toContain("failed"); expect(result.content).toHaveLength(1); - expect(result.content[0]?.text).not.toContain("## Structured Content"); + expect(textContent(result, 0)).not.toContain("## Structured Content"); expect({ ...result, content: downstreamResult.content }).toEqual({ ...downstreamResult, _meta: { @@ -970,7 +922,7 @@ describe("generated tool handlers", () => { it("extracts screenshot artifact links from call_tool text", async () => { const result = await callToolWithText("Saved [Screenshot of viewport](./file.png)"); - expect(result._meta.caplets.artifacts).toEqual([ + expect(capletsMetadata(result).artifacts).toEqual([ { kind: "screenshot", presentation: "local-path", @@ -983,7 +935,7 @@ describe("generated tool handlers", () => { it("extracts snapshot artifact links from call_tool text", async () => { const result = await callToolWithText("Saved [ARIA snapshot](./snapshot.yaml)"); - expect(result._meta.caplets.artifacts).toEqual([ + expect(capletsMetadata(result).artifacts).toEqual([ { kind: "snapshot", presentation: "local-path", @@ -996,7 +948,7 @@ describe("generated tool handlers", () => { it("extracts console-log artifact links from call_tool text", async () => { const result = await callToolWithText("Console output: [log](./browser-console.txt)"); - expect(result._meta.caplets.artifacts).toEqual([ + expect(capletsMetadata(result).artifacts).toEqual([ { kind: "console-log", presentation: "local-path", @@ -1011,7 +963,7 @@ describe("generated tool handlers", () => { "Saved [Screenshot](./screen.png), [Network log](/tmp/network.har), and [Download](files/report.pdf)", ); - expect(result._meta.caplets.artifacts).toEqual([ + expect(capletsMetadata(result).artifacts).toEqual([ { kind: "screenshot", presentation: "local-path", @@ -1054,10 +1006,10 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "read", args: {} }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ); - expect(result._meta.caplets.artifacts).toEqual([ + expect(capletsMetadata(result).artifacts).toEqual([ { kind: "file", presentation: "local-path", @@ -1087,18 +1039,20 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "read", args: {} }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ); - expect(result._meta.caplets.artifacts).toEqual([ + expect(capletsMetadata(result).artifacts).toEqual([ { kind: "file", presentation: "reference", reference: "caplets://artifacts/http/call-1/report.pdf", }, ]); - expect(result._meta.caplets.artifacts[0]).not.toHaveProperty("displayPath"); - expect(result._meta.caplets.artifacts[0]).not.toHaveProperty("pathResolution"); + const artifacts = capletsMetadata(result).artifacts; + expect(artifacts).toBeDefined(); + expect(artifacts?.[0]).not.toHaveProperty("displayPath"); + expect(artifacts?.[0]).not.toHaveProperty("pathResolution"); }); it("extracts artifact links with spaces, title attributes, and parentheses", async () => { @@ -1106,7 +1060,7 @@ describe("generated tool handlers", () => { 'Saved artifact [Screenshot](./screenshots/final view.png), file [Trace](./trace.zip "trace"), and artifact [Archive](./run(1).zip)', ); - expect(result._meta.caplets.artifacts).toEqual([ + expect(capletsMetadata(result).artifacts).toEqual([ { kind: "screenshot", presentation: "local-path", @@ -1133,13 +1087,13 @@ describe("generated tool handlers", () => { "Read [README](README.md), see [details](docs/result.md), [data](data:text/plain,hi), and [js](javascript:alert(1))", ); - expect(result._meta.caplets).not.toHaveProperty("artifacts"); + expect(capletsMetadata(result)).not.toHaveProperty("artifacts"); }); it("omits artifacts metadata when call_tool text has no artifact links", async () => { const result = await callToolWithText("No files were saved."); - expect(result._meta.caplets).not.toHaveProperty("artifacts"); + expect(capletsMetadata(result)).not.toHaveProperty("artifacts"); }); it("ignores external and fragment-only markdown links", async () => { @@ -1147,7 +1101,7 @@ describe("generated tool handlers", () => { "Ignored [site](https://example.com/a.png), [mail](mailto:a@example.com), [section](#details), [http](http://example.com/a.png)", ); - expect(result._meta.caplets).not.toHaveProperty("artifacts"); + expect(capletsMetadata(result)).not.toHaveProperty("artifacts"); }); async function callToolWithText(text: string): Promise { @@ -1161,7 +1115,7 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "read", args: {} }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ); } @@ -1191,12 +1145,12 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "read", args: { path: "x" }, fields: ["message"] }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ); - expect(result.content[0]?.text).toContain("# Alpha call_tool read"); - expect(result.content[0]?.text).toContain("## Result"); - expect(result.content[0]?.text).toContain('"message": "ok"'); + expect(textContent(result, 0)).toContain("# Alpha call_tool read"); + expect(textContent(result, 0)).toContain("## Result"); + expect(textContent(result, 0)).toContain('"message": "ok"'); expect(result).toEqual({ ...downstreamResult, content: result.content, @@ -1261,16 +1215,15 @@ describe("generated tool handlers", () => { openApiServer, { operation: "call_tool", name: "getUser", args: { id: "42" }, fields: ["body.name"] }, openApiRegistry, - downstream, - openapi, + testBackendOperationRuntime(openApiRegistry, { mcp: downstream, openapi }), ); expect(result).toMatchObject({ structuredContent: { body: { name: "Ada" } }, }); - expect(result.content[0]?.text).toContain("# Users API call_tool getUser"); - expect(result.content[0]?.text).toContain("## Body"); - expect(result.content[0]?.text).toContain('"name": "Ada"'); + expect(textContent(result, 0)).toContain("# Users API call_tool getUser"); + expect(textContent(result, 0)).toContain("## Body"); + expect(textContent(result, 0)).toContain('"name": "Ada"'); expect(openapi.getTool).toHaveBeenCalledWith(openApiServer, "getUser"); expect(openapi.callTool).toHaveBeenCalledWith(openApiServer, "getUser", { id: "42" }); expect(downstream.callTool).not.toHaveBeenCalled(); @@ -1314,18 +1267,15 @@ describe("generated tool handlers", () => { httpServer, { operation: "call_tool", name: "check", args: { id: "42" }, fields: ["ok"] }, httpRegistry, - downstream, - undefined, - undefined, - http, + testBackendOperationRuntime(httpRegistry, { mcp: downstream, http }), ); expect(result).toMatchObject({ structuredContent: { ok: true }, }); - expect(result.content[0]?.text).toContain("# Status HTTP call_tool check"); - expect(result.content[0]?.text).toContain("## Result"); - expect(result.content[0]?.text).toContain('"ok": true'); + expect(textContent(result, 0)).toContain("# Status HTTP call_tool check"); + expect(textContent(result, 0)).toContain("## Result"); + expect(textContent(result, 0)).toContain('"ok": true'); expect(http.getTool).toHaveBeenCalledWith(httpServer, "check"); expect(http.callTool).toHaveBeenCalledWith(httpServer, "check", { id: "42" }); expect(downstream.callTool).not.toHaveBeenCalled(); @@ -1423,7 +1373,7 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "read", args: { path: "x" }, fields: ["message"] }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ); expect(result).not.toBe(downstreamResult); @@ -1466,7 +1416,7 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "read", args: { path: "x" }, fields: ["message"] }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ), ).rejects.toMatchObject({ code: "DOWNSTREAM_PROTOCOL_ERROR" } satisfies Partial); }); @@ -1485,7 +1435,7 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "write", args: {}, fields: ["secret"] }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ), ).rejects.toMatchObject({ code: "REQUEST_INVALID" } satisfies Partial); expect(downstream.callTool).not.toHaveBeenCalled(); @@ -1514,7 +1464,7 @@ describe("generated tool handlers", () => { server, { operation: "call_tool", name: "read", args: {}, fields: ["secret"] }, registry, - downstream, + testBackendOperationRuntime(registry, { mcp: downstream }), ), ).rejects.toMatchObject({ code: "REQUEST_INVALID" } satisfies Partial); expect(downstream.callTool).not.toHaveBeenCalled(); @@ -1552,9 +1502,7 @@ describe("generated tool handlers", () => { graphqlCaplet as never, { operation: "call_tool", name: "query_user", args: { id: "42" } }, graphRegistry, - downstream, - undefined, - graphql, + testBackendOperationRuntime(graphRegistry, { mcp: downstream, graphql }), ); expect(result).not.toBe(graphqlResult); @@ -1608,9 +1556,7 @@ describe("generated tool handlers", () => { graphqlCaplet as never, { operation: "call_tool", name: "query_user", args: { id: "42" }, fields: ["user"] }, graphRegistry, - downstream, - undefined, - graphql, + testBackendOperationRuntime(graphRegistry, { mcp: downstream, graphql }), ), ).rejects.toMatchObject({ code: "REQUEST_INVALID", @@ -1649,10 +1595,7 @@ describe("generated tool handlers", () => { httpCaplet, { operation: "call_tool", name: "check", args: { id: "42" } }, httpRegistry, - downstream, - undefined, - undefined, - http, + testBackendOperationRuntime(httpRegistry, { mcp: downstream, http }), ); expect(result).not.toBe(httpResult); @@ -1680,3 +1623,30 @@ describe("generated tool handlers", () => { expect(downstream.callTool).not.toHaveBeenCalled(); }); }); + +function textContent(result: CallToolResult, index: number): string | undefined { + const block = result.content[index]; + return block?.type === "text" ? block.text : undefined; +} + +function capletsMetadata(result: CallToolResult): CapletResultMetadata { + const value = result._meta?.caplets; + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + !("id" in value) || + typeof value.id !== "string" || + !("name" in value) || + typeof value.name !== "string" || + !("backend" in value) || + typeof value.backend !== "string" || + !("operation" in value) || + typeof value.operation !== "string" || + !("status" in value) || + (value.status !== "ok" && value.status !== "error") + ) { + throw new Error("expected Caplets result metadata"); + } + return value as CapletResultMetadata; +} From 1dd60cfe00c8fb68c83e663376eef2a76d728357 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 10 Jul 2026 04:47:07 -0400 Subject: [PATCH 3/9] refactor(core): make exposure projection registration-ready Make one generation-bound projection authoritative for MCP, Attach, Native, and Code Mode registration. Preserve contextual completion and fail closed on stale or malformed partial discovery state. --- .changeset/calm-media-seam.md | 2 + .../content/docs/reference/code-mode-api.mdx | 1 + docs/architecture.md | 4 +- packages/core/src/attach/api.ts | 32 +- packages/core/src/cli/code-mode.ts | 39 +- packages/core/src/cli/doctor.ts | 2 + packages/core/src/code-mode/runtime-api.d.ts | 1 + .../src/code-mode/runtime-api.generated.ts | 2 +- packages/core/src/downstream.ts | 7 + packages/core/src/engine.ts | 64 +- packages/core/src/exposure/discovery.ts | 109 +-- packages/core/src/exposure/projection.ts | 310 +++++--- .../core/src/generated-tool-input-schema.ts | 23 + packages/core/src/native/remote.ts | 77 +- packages/core/src/native/service.ts | 433 +++++------ packages/core/src/runtime.ts | 16 +- packages/core/src/serve/session.ts | 705 ++++++++++++------ packages/core/src/tools.ts | 11 +- packages/core/test/attach-api.test.ts | 261 ++++--- .../test/backend-operation-dispatch.test.ts | 21 +- packages/core/test/code-mode-cli.test.ts | 63 ++ packages/core/test/code-mode-mcp.test.ts | 48 ++ packages/core/test/exposure-discovery.test.ts | 57 +- .../core/test/exposure-projection.test.ts | 146 +++- packages/core/test/fixtures/stdio-server.ts | 75 +- packages/core/test/native-remote.test.ts | 28 +- packages/core/test/native.test.ts | 305 +++++++- packages/core/test/runtime.test.ts | 88 ++- packages/core/test/serve-session.test.ts | 471 +++++++++++- 29 files changed, 2518 insertions(+), 883 deletions(-) diff --git a/.changeset/calm-media-seam.md b/.changeset/calm-media-seam.md index 76d879b6..1445a9cb 100644 --- a/.changeset/calm-media-seam.md +++ b/.changeset/calm-media-seam.md @@ -8,3 +8,5 @@ Unify HTTP-like non-inline results behind explicit local-artifact and remote-ref GraphQL operation results now share the Media pipeline with a 1 MiB inline threshold and a 100 MiB artifact cap. Pi renders local artifact paths and remote artifact references according to the result variant. Replace `handleServerTool`'s positional manager arguments with a named backend runtime. External `@caplets/core` callers now construct that runtime with `createBackendOperationRuntime`; common backend operations dispatch through its `operations` Interface, while MCP-only resource, prompt, and completion methods remain on `runtime.mcp`. + +Make exposure projection the generation-bound callable-surface authority. MCP, Attach, and native adapters now render registration facts and Code Mode identities from the same projection, reject stale callbacks across reloads, discard out-of-order discovery, and keep hidden or unresolved Caplets out of declarations and execution allowlists. diff --git a/apps/docs/src/content/docs/reference/code-mode-api.mdx b/apps/docs/src/content/docs/reference/code-mode-api.mdx index aed6310f..ad91547b 100644 --- a/apps/docs/src/content/docs/reference/code-mode-api.mdx +++ b/apps/docs/src/content/docs/reference/code-mode-api.mdx @@ -293,6 +293,7 @@ type PromptResult = unknown; type CompleteInput = { ref: { type: "prompt"; name: string } | { type: "resourceTemplate"; uri: string }; argument: { name: string; value: string }; + context?: { arguments?: Record }; }; ``` diff --git a/docs/architecture.md b/docs/architecture.md index 6f62dba2..f8697c71 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,9 @@ Backend managers provide a common shape for listing tools, searching tools, desc The global default is `code_mode`. Per-Caplet config may choose `direct`, `progressive`, `code_mode`, `direct_and_code_mode`, or `progressive_and_code_mode`. -`packages/core/src/exposure/projection.ts` turns resolved discovery snapshots and attach manifests into the Caplets exposure projection: the adapter-neutral view of Code Mode handles, progressive tools, direct downstream operations, direct MCP surfaces, route descriptors, hidden diagnostic breadcrumbs, and local/remote merge outcomes. MCP serving, native integrations, and attach/remote clients render this projection; they do not re-own exposure identity, namespace shadowing, or hidden-Caplet policy. +`packages/core/src/exposure/projection.ts` turns resolved discovery snapshots and attach manifests into the Caplets exposure projection: the adapter-neutral, registration-ready view of Code Mode handles, progressive tools, direct downstream operations, direct MCP surfaces, schemas, prompt arguments, resource metadata, route descriptors, hidden diagnostic breadcrumbs, and local/remote merge outcomes. MCP serving, native integrations, and attach/remote clients render this projection; they do not re-own exposure identity, namespace shadowing, registration facts, or hidden-Caplet policy. + +The engine tags each projection with the config generation captured before discovery. Adapters publish only a projection that still matches the current generation, discard out-of-order discovery, and reject callbacks rendered from an older generation. Until initial or refreshed discovery resolves, Code Mode declarations and native execution allowlists fail closed rather than falling back to configured Caplet IDs. ### MCP Server diff --git a/packages/core/src/attach/api.ts b/packages/core/src/attach/api.ts index 1b4545d3..dafa8c5e 100644 --- a/packages/core/src/attach/api.ts +++ b/packages/core/src/attach/api.ts @@ -9,7 +9,6 @@ import { directResourceUriMatchesTemplate, } from "../exposure/direct-names"; import { - buildExposureProjection, type ExposureProjectionEntry, type ExposureProjectionHiddenCaplet, } from "../exposure/projection"; @@ -60,6 +59,8 @@ export type AttachManifestExport = { capletId: string; sourceCapletId?: string | undefined; shadowing: CapletShadowingPolicy; + useWhen?: string | undefined; + avoidWhen?: string | undefined; }; export type AttachProgressiveCapletExport = AttachManifestExport & { @@ -143,7 +144,14 @@ type AttachManifestProjectionInput = { }; export async function buildAttachProjection(engine: CapletsEngine): Promise { - const projection = buildExposureProjection(await engine.exposureSnapshot()); + const resolved = await engine.exposureProjection(); + if (resolved.generation !== engine.currentExposureGeneration()) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + "Attach exposure changed while the manifest was being generated.", + ); + } + const projection = resolved.projection; const partial = sortAttachProjectionInput({ caplets: projection.entries.flatMap(progressiveCapletExport), tools: projection.entries.flatMap(toolExport), @@ -243,6 +251,8 @@ function nativeProgressiveCaplets( schemaHash: schemaHash(tool.inputSchema ?? null), capletId: tool.caplet, ...(tool.sourceCaplet ? { sourceCapletId: tool.sourceCaplet } : {}), + ...(tool.useWhen ? { useWhen: tool.useWhen } : {}), + ...(tool.avoidWhen ? { avoidWhen: tool.avoidWhen } : {}), shadowing: tool.shadowing ?? "forbid", })); } @@ -266,6 +276,8 @@ function nativeDirectTools( annotations: tool.annotations, schemaHash: schemaHash({ input: tool.inputSchema, output: tool.outputSchema }), capletId: tool.sourceCaplet, + ...(tool.useWhen ? { useWhen: tool.useWhen } : {}), + ...(tool.avoidWhen ? { avoidWhen: tool.avoidWhen } : {}), shadowing: tool.shadowing ?? "forbid", }, ]; @@ -294,6 +306,8 @@ function nativeCodeModeCaplets( schemaHash: null, capletId: caplet.id, ...(caplet.sourceCapletId ? { sourceCapletId: caplet.sourceCapletId } : {}), + ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), + ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), shadowing: caplet.shadowing ?? "forbid", })), ); @@ -417,6 +431,8 @@ function progressiveCapletExport( inputSchema: entry.inputSchema, schemaHash: schemaHash(entry.inputSchema ?? null), capletId: entry.capletId, + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, }, ]; @@ -435,6 +451,8 @@ function codeModeCapletExport( description: entry.description, schemaHash: null, capletId: entry.capletId, + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, }, ]; @@ -455,6 +473,8 @@ function toolExport(entry: ExposureProjectionEntry): Array; @@ -63,7 +62,9 @@ export async function runCodeModeCli(options: CodeModeCliOptions): Promise options.setExitCode(1); return; } + const initialProjection = service.reload(); const code = await readCodeModeCliCode(options); + await initialProjection; const started = Date.now(); const result = await runCodeMode({ code, @@ -150,7 +151,7 @@ export async function codeModeTypesCli( telemetryStateDir: options.telemetryStateDir ?? defaultTelemetryStateDir(options.env), }); try { - const caplets = listCodeModeCallableCaplets(engine); + const caplets = await listCodeModeCallableCaplets(engine); const declaration = generateCodeModeDeclarations({ caplets }); if (!options.json) { options.writeOut(declaration); @@ -197,20 +198,26 @@ function runtimeScope(env: NodeJS.ProcessEnv | Record { - if (caplet.setup || caplet.projectBinding?.required) return false; - return resolveExposure(caplet.exposure, defaultExposure).codeMode; +async function listCodeModeCallableCaplets( + engine: CapletsEngine, +): Promise { + const { projection } = await engine.exposureProjection({ + discoverNonDirectMcpSurfaces: false, + }); + return projection.entries + .flatMap((entry) => { + if (entry.kind !== "code-mode-caplet") return []; + return [ + { + id: entry.id, + ...(entry.sourceCapletId ? { sourceCapletId: entry.sourceCapletId } : {}), + name: entry.title ?? entry.id, + description: entry.description ?? "", + shadowing: entry.shadowing, + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), + }, + ]; }) - .map((caplet) => ({ - id: caplet.server, - name: caplet.name, - description: caplet.description, - ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), - ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), - })) .sort((left, right) => left.id.localeCompare(right.id)); } diff --git a/packages/core/src/cli/doctor.ts b/packages/core/src/cli/doctor.ts index d73dfe33..d1b8e831 100644 --- a/packages/core/src/cli/doctor.ts +++ b/packages/core/src/cli/doctor.ts @@ -585,6 +585,7 @@ async function resolveExposureSection(env: NodeJS.ProcessEnv | Record undefined, }); try { + await service.reload(); const nativeTools = service.listTools(); const callableIds = new Set(nativeTools.map((tool) => tool.caplet)); return { @@ -827,6 +828,7 @@ async function resolveCallableIndexDoctor( writeErr: () => undefined, }); try { + await service.reload(); return { ok: true, callableCount: listCodeModeCallableCaplets(service).length }; } finally { await service.close(); diff --git a/packages/core/src/code-mode/runtime-api.d.ts b/packages/core/src/code-mode/runtime-api.d.ts index 49dc722b..e129ee82 100644 --- a/packages/core/src/code-mode/runtime-api.d.ts +++ b/packages/core/src/code-mode/runtime-api.d.ts @@ -120,6 +120,7 @@ type PromptResult = unknown; type CompleteInput = { ref: { type: "prompt"; name: string } | { type: "resourceTemplate"; uri: string }; argument: { name: string; value: string }; + context?: { arguments?: Record }; }; type CompleteResult = unknown; diff --git a/packages/core/src/code-mode/runtime-api.generated.ts b/packages/core/src/code-mode/runtime-api.generated.ts index c2b82428..60f2ea6b 100644 --- a/packages/core/src/code-mode/runtime-api.generated.ts +++ b/packages/core/src/code-mode/runtime-api.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/generate-code-mode-runtime-api.mjs. Do not edit by hand. export const CODE_MODE_RUNTIME_API_DECLARATION = - 'type JsonPrimitive=string|number|boolean|null;type JsonValue=JsonPrimitive|JsonValue[]|{[key:string]:JsonValue};interface CapletHandle{readonly id:Id;/** Show this Caplet card,without tool/resource/prompt schemas. */ inspect():Promise>;/** Check backend readiness/auth;expected unavailable states return ok:false. */ check():Promise>;/** List tool summaries for the discovery pass;may be empty. */ tools(input?:PageInput):Promise>;/** Search tool summaries for the discovery pass;may be empty. */ searchTools(query:string,input?:PageInput):Promise>;/** Get schema,callSignature,types,examples;prefer outputSchema/outputTypeScript over observed hints. */ describeTool(name:string):Promise>;/** Call one tool;expected failures return ok:false. Filter bulky data in script before returning. */ callTool(name:string,args?:unknown):Promise>;/** List readable resources for the discovery pass;many backends expose none. */ resources(input?:PageInput):Promise>;/** Search readable resources for the discovery pass;many backends expose none. */ searchResources(query:string,input?:PageInput):Promise>;/** List resource templates for the discovery pass;many backends expose none. */ resourceTemplates(input?:PageInput):Promise>;/** Read one resource by URI;unsupported/missing resources return ok:false. */ readResource(uri:string):Promise>;/** List reusable prompts for the discovery pass;many backends expose none. */ prompts(input?:PageInput):Promise>;/** Search reusable prompts for the discovery pass;many backends expose none. */ searchPrompts(query:string,input?:PageInput):Promise>;/** Get one prompt by name and args;unsupported/missing prompts return ok:false. */ getPrompt(name:string,args?:unknown):Promise>;/** Complete a prompt or resource-template argument. */ complete(input:CompleteInput):Promise>;}interface DebugApi{readLogs(input:ReadLogsInput):Promise;readRecovery(input:ReadCodeModeRecoveryInput):Promise;}type CapletCard={id:Id;name:string;description:string;useWhen?:string;avoidWhen?:string;tags?:string[];backend?:unknown;};type PageInput={limit?:number;cursor?:string};type Page={items:T[];nextCursor?:string;truncated?:boolean};type CapletsResult=|{ok:true;data:T;meta?:CapletsMeta}|{ok:false;error:CapletsError;meta?:CapletsMeta};type CapletsMeta={[key:string]:unknown};type CapletsError={code:string;message:string;details?:unknown};type BackendCheckResult=unknown;type ToolSummary={/** Exact downstream tool identifier for describeTool(name)and callTool(name,args). */ name:string;title?:string;description?:string;/** Optional author-supplied hint for when to prefer this tool. */ useWhen?:string;/** Optional author-supplied hint for when to avoid this tool. */ avoidWhen?:string;/** True when the tool declares that it only reads data. */ readOnlyHint?:boolean;/** True when the tool declares that it may perform destructive writes. */ destructiveHint?:boolean;};type ToolDescriptor={id?:string;tool?:unknown;inputSchema?:unknown;outputSchema?:unknown;callSignature?:string;inputTypeScript?:string;outputTypeScript?:string;observedOutputShape?:ObservedOutputShape;examples?:unknown[];};type ObservedOutputShape={version:1;source:"observed";observedAt:string;sampleCount:number;typeScript:string;jsonShape:JsonShape;truncated:boolean;};type JsonShape=|{kind:"null"}|{kind:"boolean"}|{kind:"number"}|{kind:"string"}|{kind:"unknown"}|{kind:"array";element?:JsonShape;truncated?:boolean}|{kind:"object";fields:Record;truncated?:boolean;}|{kind:"union";variants:JsonShape[]};type ResourceSummary={uri?:string;name?:string;title?:string;description?:string};type ResourceTemplateSummary={uriTemplate?:string;name?:string;title?:string;description?:string;};type ResourceReadResult=unknown;type PromptSummary={name?:string;title?:string;description?:string};type PromptResult=unknown;type CompleteInput={ref:{type:"prompt";name:string}|{type:"resourceTemplate";uri:string};argument:{name:string;value:string};};type CompleteResult=unknown;type ReadLogsInput={logRef:string;cursor?:string;limit?:number};type ReadLogsResult={entries:CodeModeLogEntry[];nextCursor?:string};type ReadCodeModeRecoveryInput={recoveryRef:string;cursor?:string;limit?:number};type CodeModeDiagnostic={code:string;message:string;severity:"error"|"warning"|"info";line?:number;column?:number;};type CodeModeRecoveryClassification="setup_like"|"side_effecting"|"unknown";type CodeModeRecoveryEntry={timestamp:string;code:string;declarationHash:string;outcome:{ok:true}|{ok:false;code:string;message:string};diagnostics:Array>;recoveryClassification:CodeModeRecoveryClassification;logsStored?:boolean;summary?:string;};type ReadCodeModeRecoveryResult={entries:CodeModeRecoveryEntry[];nextCursor?:string};type CodeModeLogEntry={level:"log"|"info"|"warn"|"error"|"debug";message:string;timestamp:string;};type CodeModeSessionStatus="created"|"reused";type CodeModeRunMeta={runId:string;traceId:string;declarationHash:string;durationMs:number;timeoutMs:number;maxTimeoutMs:number;sessionId?:string|null;sessionStatus?:CodeModeSessionStatus|null;recoveryRef?:string|null;};interface Console{log(...values:unknown[]):void;info(...values:unknown[]):void;warn(...values:unknown[]):void;error(...values:unknown[]):void;debug(...values:unknown[]):void;}declare const console:Console;' as const; + 'type JsonPrimitive=string|number|boolean|null;type JsonValue=JsonPrimitive|JsonValue[]|{[key:string]:JsonValue};interface CapletHandle{readonly id:Id;/** Show this Caplet card,without tool/resource/prompt schemas. */ inspect():Promise>;/** Check backend readiness/auth;expected unavailable states return ok:false. */ check():Promise>;/** List tool summaries for the discovery pass;may be empty. */ tools(input?:PageInput):Promise>;/** Search tool summaries for the discovery pass;may be empty. */ searchTools(query:string,input?:PageInput):Promise>;/** Get schema,callSignature,types,examples;prefer outputSchema/outputTypeScript over observed hints. */ describeTool(name:string):Promise>;/** Call one tool;expected failures return ok:false. Filter bulky data in script before returning. */ callTool(name:string,args?:unknown):Promise>;/** List readable resources for the discovery pass;many backends expose none. */ resources(input?:PageInput):Promise>;/** Search readable resources for the discovery pass;many backends expose none. */ searchResources(query:string,input?:PageInput):Promise>;/** List resource templates for the discovery pass;many backends expose none. */ resourceTemplates(input?:PageInput):Promise>;/** Read one resource by URI;unsupported/missing resources return ok:false. */ readResource(uri:string):Promise>;/** List reusable prompts for the discovery pass;many backends expose none. */ prompts(input?:PageInput):Promise>;/** Search reusable prompts for the discovery pass;many backends expose none. */ searchPrompts(query:string,input?:PageInput):Promise>;/** Get one prompt by name and args;unsupported/missing prompts return ok:false. */ getPrompt(name:string,args?:unknown):Promise>;/** Complete a prompt or resource-template argument. */ complete(input:CompleteInput):Promise>;}interface DebugApi{readLogs(input:ReadLogsInput):Promise;readRecovery(input:ReadCodeModeRecoveryInput):Promise;}type CapletCard={id:Id;name:string;description:string;useWhen?:string;avoidWhen?:string;tags?:string[];backend?:unknown;};type PageInput={limit?:number;cursor?:string};type Page={items:T[];nextCursor?:string;truncated?:boolean};type CapletsResult=|{ok:true;data:T;meta?:CapletsMeta}|{ok:false;error:CapletsError;meta?:CapletsMeta};type CapletsMeta={[key:string]:unknown};type CapletsError={code:string;message:string;details?:unknown};type BackendCheckResult=unknown;type ToolSummary={/** Exact downstream tool identifier for describeTool(name)and callTool(name,args). */ name:string;title?:string;description?:string;/** Optional author-supplied hint for when to prefer this tool. */ useWhen?:string;/** Optional author-supplied hint for when to avoid this tool. */ avoidWhen?:string;/** True when the tool declares that it only reads data. */ readOnlyHint?:boolean;/** True when the tool declares that it may perform destructive writes. */ destructiveHint?:boolean;};type ToolDescriptor={id?:string;tool?:unknown;inputSchema?:unknown;outputSchema?:unknown;callSignature?:string;inputTypeScript?:string;outputTypeScript?:string;observedOutputShape?:ObservedOutputShape;examples?:unknown[];};type ObservedOutputShape={version:1;source:"observed";observedAt:string;sampleCount:number;typeScript:string;jsonShape:JsonShape;truncated:boolean;};type JsonShape=|{kind:"null"}|{kind:"boolean"}|{kind:"number"}|{kind:"string"}|{kind:"unknown"}|{kind:"array";element?:JsonShape;truncated?:boolean}|{kind:"object";fields:Record;truncated?:boolean;}|{kind:"union";variants:JsonShape[]};type ResourceSummary={uri?:string;name?:string;title?:string;description?:string};type ResourceTemplateSummary={uriTemplate?:string;name?:string;title?:string;description?:string;};type ResourceReadResult=unknown;type PromptSummary={name?:string;title?:string;description?:string};type PromptResult=unknown;type CompleteInput={ref:{type:"prompt";name:string}|{type:"resourceTemplate";uri:string};argument:{name:string;value:string};context?:{arguments?:Record};};type CompleteResult=unknown;type ReadLogsInput={logRef:string;cursor?:string;limit?:number};type ReadLogsResult={entries:CodeModeLogEntry[];nextCursor?:string};type ReadCodeModeRecoveryInput={recoveryRef:string;cursor?:string;limit?:number};type CodeModeDiagnostic={code:string;message:string;severity:"error"|"warning"|"info";line?:number;column?:number;};type CodeModeRecoveryClassification="setup_like"|"side_effecting"|"unknown";type CodeModeRecoveryEntry={timestamp:string;code:string;declarationHash:string;outcome:{ok:true}|{ok:false;code:string;message:string};diagnostics:Array>;recoveryClassification:CodeModeRecoveryClassification;logsStored?:boolean;summary?:string;};type ReadCodeModeRecoveryResult={entries:CodeModeRecoveryEntry[];nextCursor?:string};type CodeModeLogEntry={level:"log"|"info"|"warn"|"error"|"debug";message:string;timestamp:string;};type CodeModeSessionStatus="created"|"reused";type CodeModeRunMeta={runId:string;traceId:string;declarationHash:string;durationMs:number;timeoutMs:number;maxTimeoutMs:number;sessionId?:string|null;sessionStatus?:CodeModeSessionStatus|null;recoveryRef?:string|null;};interface Console{log(...values:unknown[]):void;info(...values:unknown[]):void;warn(...values:unknown[]):void;error(...values:unknown[]):void;debug(...values:unknown[]):void;}declare const console:Console;' as const; diff --git a/packages/core/src/downstream.ts b/packages/core/src/downstream.ts index 0a627cb1..c5bcd9ce 100644 --- a/packages/core/src/downstream.ts +++ b/packages/core/src/downstream.ts @@ -374,11 +374,17 @@ export class DownstreamManager { } } + async supportsCompletions(server: CapletServerConfig): Promise { + const connection = await this.connect(server); + return Boolean(connection.client.getServerCapabilities()?.completions); + } + async complete( server: CapletServerConfig, request: { ref: { type: "prompt"; name: string } | { type: "resourceTemplate"; uri: string }; argument: { name: string; value: string }; + context?: { arguments?: Record | undefined } | undefined; }, ) { const connection = await this.assertCapability(server, "completions"); @@ -388,6 +394,7 @@ export class DownstreamManager { ? { type: "ref/prompt", name: request.ref.name } : { type: "ref/resource", uri: request.ref.uri }, argument: request.argument, + ...(request.context ? { context: request.context } : {}), }; try { return await connection.client.complete(params, { timeout: server.callTimeoutMs }); diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index d917f519..20de3c4d 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -33,6 +33,7 @@ import type { ProjectBindingExecutionContext } from "./project-binding/execution import { ServerRegistry } from "./registry"; import { extractArtifacts, handleServerTool } from "./tools"; import { discoverExposureSnapshot, type ExposureSnapshot } from "./exposure/discovery"; +import { buildExposureProjection, type ExposureProjection } from "./exposure/projection"; import { captureRuntimeReliabilityEvent, captureRuntimeTelemetryEvent, @@ -52,6 +53,11 @@ import type { type ToolSummary = { name: string; description?: string }; +export type ResolvedExposureProjection = { + generation: number; + projection: ExposureProjection; +}; + export type CapletsEngineOptions = { configPath?: string; projectConfigPath?: string; @@ -122,7 +128,7 @@ export class CapletsEngine { private readonly telemetry: RuntimeTelemetryContext; private readonly telemetryExecuteExposureMode: "progressive" | "code_mode"; private readonly reloadListeners = new Set<(event: CapletsEngineReloadEvent) => void>(); - private lastExposureSnapshot: ExposureSnapshot | undefined; + private exposureGeneration = 0; private watchers: FSWatcher[] = []; private reloadTimer: NodeJS.Timeout | undefined; private watcherRefreshTimer: NodeJS.Timeout | undefined; @@ -200,12 +206,28 @@ export class CapletsEngine { return nextEnabledServers(this.registry.config); } + currentExposureGeneration(): number { + return this.exposureGeneration; + } + + async exposureProjection( + options: { discoverNonDirectMcpSurfaces?: boolean | undefined } = {}, + ): Promise { + const generation = this.exposureGeneration; + return { + generation, + projection: buildExposureProjection(await this.exposureSnapshot(options)), + }; + } + async exposureSnapshot( options: { discoverNonDirectMcpSurfaces?: boolean | undefined } = {}, ): Promise { - this.lastExposureSnapshot = await discoverExposureSnapshot({ - config: this.registry.config, - caplets: this.enabledServers(), + const config = this.registry.config; + const caplets = allCaplets(config); + return await discoverExposureSnapshot({ + config, + caplets, ...(options.discoverNonDirectMcpSurfaces === undefined ? {} : { discoverNonDirectMcpSurfaces: options.discoverNonDirectMcpSurfaces }), @@ -219,12 +241,8 @@ export class CapletsEngine { this.optionalMcpList(caplet, () => this.downstream.listResourceTemplates(caplet, true)), listPrompts: async (caplet) => this.optionalMcpList(caplet, () => this.downstream.listPrompts(caplet, true)), + supportsCompletions: async (caplet) => await this.downstream.supportsCompletions(caplet), }); - return this.lastExposureSnapshot; - } - - currentExposureSnapshot(): ExposureSnapshot | undefined { - return this.lastExposureSnapshot; } currentProjectBindingContext(): ProjectBindingExecutionContext | undefined { @@ -328,6 +346,33 @@ export class CapletsEngine { } } + async completeDirectReference( + serverId: string, + ref: { type: "prompt"; name: string } | { type: "resourceTemplate"; uri: string }, + argument: { name: string; value: string }, + context?: { arguments?: Record | undefined } | undefined, + ): Promise { + const started = Date.now(); + let caplet: CapletConfig | undefined; + try { + caplet = this.registry.require(serverId); + this.assertProjectBindingCallable(caplet); + if (caplet.backend !== "mcp") throw new Error(`Caplet ${serverId} has no MCP completions`); + const result = await this.downstream.complete(caplet, { + ref, + argument, + ...(context ? { context } : {}), + }); + this.captureToolActivation(caplet, "complete", "direct", result, started); + return result.completion.values; + } catch (error) { + const result = errorResult(error); + this.captureReliabilityError("complete", "direct", result); + this.captureToolActivation(caplet, "complete", "direct", result, started); + throw error; + } + } + async readDirectResource(serverId: string, downstreamUri: string): Promise { const started = Date.now(); let caplet: CapletConfig | undefined; @@ -507,6 +552,7 @@ export class CapletsEngine { const previousConfig = this.registry.config; const nextRegistry = new ServerRegistry(nextConfig); this.registry = nextRegistry; + this.exposureGeneration += 1; this.telemetry.config = nextConfig; this.downstream.updateRegistry(nextRegistry); this.openapi.updateRegistry(nextRegistry); diff --git a/packages/core/src/exposure/discovery.ts b/packages/core/src/exposure/discovery.ts index 33cfba67..e8d721d6 100644 --- a/packages/core/src/exposure/discovery.ts +++ b/packages/core/src/exposure/discovery.ts @@ -7,12 +7,6 @@ import { } from "../project-binding/errors"; import type { ProjectBindingExecutionContext } from "../project-binding/execution-context"; import type { ProjectBindingQuarantineRecord } from "../project-binding/types"; -import { - directPromptName, - directResourceTemplateUri, - directResourceUri, - directToolName, -} from "./direct-names"; import { resolveExposure, type ResolvedExposure } from "./policy"; export type HiddenCapletReason = @@ -44,45 +38,12 @@ export type CallableCaplet = { resources: Resource[]; resourceTemplates: ResourceTemplate[]; prompts: Prompt[]; + completions: boolean; discoveredAt: number; }; -export type DirectToolRegistration = { - caplet: CapletConfig; - downstreamName: string; - name: string; - tool: Tool; -}; - -export type DirectResourceRegistration = { - caplet: Extract; - downstreamUri: string; - uri: string; - resource: Resource; -}; - -export type DirectResourceTemplateRegistration = { - caplet: Extract; - downstreamUriTemplate: string; - uriTemplate: string; - resourceTemplate: ResourceTemplate; -}; - -export type DirectPromptRegistration = { - caplet: Extract; - downstreamName: string; - name: string; - prompt: Prompt; -}; - export type ExposureSnapshot = { callableCaplets: CallableCaplet[]; - progressiveCaplets: CallableCaplet[]; - codeModeCaplets: CallableCaplet[]; - directTools: DirectToolRegistration[]; - directResources: DirectResourceRegistration[]; - directResourceTemplates: DirectResourceTemplateRegistration[]; - directPrompts: DirectPromptRegistration[]; hiddenCaplets: HiddenCaplet[]; }; @@ -97,6 +58,7 @@ export type DiscoverExposureSnapshotOptions = { caplet: Extract, ): Promise; listPrompts?(caplet: Extract): Promise; + supportsCompletions?(caplet: Extract): Promise; }; export async function discoverExposureSnapshot( @@ -108,66 +70,12 @@ export async function discoverExposureSnapshot( async (caplet) => discoverCaplet(options, caplet), ); - const callableCaplets = results.flatMap((result) => (result.callable ? [result.callable] : [])); - const hiddenCaplets = results.flatMap((result) => (result.hidden ? [result.hidden] : [])); return { - callableCaplets, - progressiveCaplets: callableCaplets.filter((entry) => entry.exposure.progressive), - codeModeCaplets: callableCaplets.filter((entry) => entry.exposure.codeMode), - directTools: callableCaplets.flatMap((entry) => - entry.exposure.direct - ? entry.tools.map((tool) => ({ - caplet: entry.caplet, - downstreamName: tool.name, - name: directToolName(entry.caplet.server, tool.name), - tool, - })) - : [], - ), - directResources: callableCaplets.flatMap(directResourcesFor), - directResourceTemplates: callableCaplets.flatMap(directResourceTemplatesFor), - directPrompts: callableCaplets.flatMap(directPromptsFor), - hiddenCaplets, + callableCaplets: results.flatMap((result) => (result.callable ? [result.callable] : [])), + hiddenCaplets: results.flatMap((result) => (result.hidden ? [result.hidden] : [])), }; } -function directResourcesFor(entry: CallableCaplet): DirectResourceRegistration[] { - if (!entry.exposure.direct || !isMcpCaplet(entry.caplet)) return []; - const caplet = entry.caplet; - return entry.resources.map((resource) => ({ - caplet, - downstreamUri: resource.uri, - uri: directResourceUri(caplet.server, resource.uri), - resource, - })); -} - -function directResourceTemplatesFor(entry: CallableCaplet): DirectResourceTemplateRegistration[] { - if (!entry.exposure.direct || !isMcpCaplet(entry.caplet)) return []; - const caplet = entry.caplet; - return entry.resourceTemplates.map((resourceTemplate) => ({ - caplet, - downstreamUriTemplate: resourceTemplate.uriTemplate, - uriTemplate: directResourceTemplateUri(caplet.server, resourceTemplate.uriTemplate), - resourceTemplate, - })); -} - -function directPromptsFor(entry: CallableCaplet): DirectPromptRegistration[] { - if (!entry.exposure.direct || !isMcpCaplet(entry.caplet)) return []; - const caplet = entry.caplet; - return entry.prompts.map((prompt) => ({ - caplet, - downstreamName: prompt.name, - name: directPromptName(caplet.server, prompt.name), - prompt, - })); -} - -function isMcpCaplet(caplet: CapletConfig): caplet is Extract { - return caplet.backend === "mcp"; -} - async function discoverCaplet( options: DiscoverExposureSnapshotOptions, caplet: CapletConfig, @@ -210,6 +118,7 @@ async function discoverCaplet( resources: [], resourceTemplates: [], prompts: [], + completions: false, discoveredAt: Date.now(), }, }; @@ -240,6 +149,13 @@ async function discoverCaplet( options.config.options.exposureDiscoveryTimeoutMs, ) : []; + const completions = + caplet.backend === "mcp" && options.supportsCompletions + ? await withTimeout( + options.supportsCompletions(caplet), + options.config.options.exposureDiscoveryTimeoutMs, + ) + : false; if ( tools.length === 0 && resources.length === 0 && @@ -256,6 +172,7 @@ async function discoverCaplet( resources, resourceTemplates, prompts, + completions, discoveredAt: Date.now(), }, }; diff --git a/packages/core/src/exposure/projection.ts b/packages/core/src/exposure/projection.ts index acdf62db..6eb96729 100644 --- a/packages/core/src/exposure/projection.ts +++ b/packages/core/src/exposure/projection.ts @@ -1,4 +1,4 @@ -import type { CapletShadowingPolicy } from "../config"; +import type { CapletConfig, CapletShadowingPolicy } from "../config"; import { resolveNamespaceExposure, type NamespaceDiagnostic, @@ -6,12 +6,15 @@ import { } from "./namespace"; import type { SafeErrorSummary } from "../errors"; import { generatedToolInputJsonSchemaForCaplet } from "../generated-tool-input-schema"; +import { capabilityDescription } from "../registry"; +import { + directPromptName, + directResourceTemplateUri, + directResourceUri, + directToolName, +} from "./direct-names"; import type { CallableCaplet, - DirectPromptRegistration, - DirectResourceRegistration, - DirectResourceTemplateRegistration, - DirectToolRegistration, ExposureSnapshot, HiddenCaplet, HiddenCapletReason, @@ -42,22 +45,90 @@ export type ExposureProjectionRoute = | { kind: "direct-prompt"; capletId: string; downstreamName: string } | { kind: "completion"; capletId: string }; -export type ExposureProjectionEntry = { - kind: ExposureProjectionEntryKind; +type ExposureProjectionEntryBase< + Kind extends ExposureProjectionEntryKind, + Route extends ExposureProjectionRoute, +> = { + kind: Kind; id: string; capletId: string; title?: string | undefined; description?: string | undefined; + sourceCapletId?: string | undefined; + shadowing: CapletShadowingPolicy; + useWhen?: string | undefined; + avoidWhen?: string | undefined; + route: Route; +}; + +export type ExposureProjectionProgressiveCaplet = ExposureProjectionEntryBase< + "progressive-caplet", + Extract +> & { + backend?: CapletConfig["backend"] | undefined; + inputSchema?: unknown; + operationNames?: string[] | undefined; +}; + +export type ExposureProjectionCodeModeCaplet = ExposureProjectionEntryBase< + "code-mode-caplet", + Extract +> & { + backend?: CapletConfig["backend"] | undefined; +}; + +export type ExposureProjectionDirectTool = ExposureProjectionEntryBase< + "direct-tool", + Extract +> & { inputSchema?: unknown; outputSchema?: unknown; annotations?: unknown; +}; + +export type ExposureProjectionDirectResource = ExposureProjectionEntryBase< + "direct-resource", + Extract +> & { mimeType?: string | undefined; size?: number | undefined; - sourceCapletId?: string | undefined; - shadowing: CapletShadowingPolicy; - route: ExposureProjectionRoute; }; +export type ExposureProjectionDirectResourceTemplate = ExposureProjectionEntryBase< + "direct-resource-template", + Extract +> & { + mimeType?: string | undefined; +}; + +export type ExposureProjectionPromptArgument = { + name: string; + description?: string | undefined; + required?: boolean | undefined; +}; + +export type ExposureProjectionDirectPrompt = ExposureProjectionEntryBase< + "direct-prompt", + Extract +> & { + inputSchema?: unknown; + arguments: ExposureProjectionPromptArgument[]; +}; + +export type ExposureProjectionCompletion = ExposureProjectionEntryBase< + "completion", + Extract +>; + +export type ExposureProjectionEntry = + | ExposureProjectionProgressiveCaplet + | ExposureProjectionCodeModeCaplet + | ExposureProjectionDirectTool + | ExposureProjectionDirectResource + | ExposureProjectionDirectResourceTemplate + | ExposureProjectionDirectPrompt + | ExposureProjectionCompletion; + export type ExposureProjectionHiddenCaplet = { capletId: string; reason: HiddenCapletReason; @@ -78,15 +149,7 @@ export function exposureProjectionRouteKey( } export function buildExposureProjection(snapshot: ExposureSnapshot): ExposureProjection { - const entries = [ - ...snapshot.progressiveCaplets.map(progressiveCapletEntry), - ...snapshot.codeModeCaplets.map(codeModeCapletEntry), - ...snapshot.directTools.map(directToolEntry), - ...snapshot.directResources.map(directResourceEntry), - ...snapshot.directResourceTemplates.map(directResourceTemplateEntry), - ...snapshot.directPrompts.map(directPromptEntry), - ...completionEntries(snapshot), - ]; + const entries = snapshot.callableCaplets.flatMap(entriesForCallableCaplet); return { availability: { state: "ready" }, @@ -115,6 +178,8 @@ type ManifestProjectionBase = { outputSchema?: unknown; annotations?: unknown; shadowing: CapletShadowingPolicy; + useWhen?: string | undefined; + avoidWhen?: string | undefined; }; type ManifestProjectionCaplet = ManifestProjectionBase & { @@ -188,6 +253,8 @@ function manifestProgressiveCapletEntry(entry: ManifestProjectionCaplet): Exposu title: entry.title ?? entry.name, description: entry.description, inputSchema: entry.inputSchema, + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "progressive-caplet", capletId: entry.capletId }, }; @@ -204,6 +271,8 @@ function manifestDirectToolEntry(entry: ManifestProjectionTool): ExposureProject inputSchema: entry.inputSchema, outputSchema: entry.outputSchema, annotations: entry.annotations, + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "direct-tool", capletId: entry.capletId, downstreamName: entry.downstreamName }, }; @@ -219,6 +288,8 @@ function manifestDirectResourceEntry(entry: ManifestProjectionResource): Exposur description: entry.description, ...(entry.mimeType ? { mimeType: entry.mimeType } : {}), ...(typeof entry.size === "number" ? { size: entry.size } : {}), + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "direct-resource", @@ -239,6 +310,8 @@ function manifestDirectResourceTemplateEntry( title: entry.title, description: entry.description, ...(entry.mimeType ? { mimeType: entry.mimeType } : {}), + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "direct-resource-template", @@ -257,6 +330,9 @@ function manifestDirectPromptEntry(entry: ManifestProjectionPrompt): ExposurePro title: entry.title ?? entry.name, description: entry.description, inputSchema: entry.inputSchema, + arguments: promptArgumentsFromSchema(entry.inputSchema), + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "direct-prompt", @@ -266,6 +342,24 @@ function manifestDirectPromptEntry(entry: ManifestProjectionPrompt): ExposurePro }; } +function promptArgumentsFromSchema(inputSchema: unknown): ExposureProjectionPromptArgument[] { + if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) return []; + const args = (inputSchema as Record).arguments; + if (!Array.isArray(args)) return []; + return args.flatMap((argument) => { + if (!argument || typeof argument !== "object" || Array.isArray(argument)) return []; + const record = argument as Record; + if (typeof record.name !== "string") return []; + return [ + { + name: record.name, + ...(typeof record.description === "string" ? { description: record.description } : {}), + ...(typeof record.required === "boolean" ? { required: record.required } : {}), + }, + ]; + }); +} + function manifestCompletionEntry(entry: ManifestProjectionCompletion): ExposureProjectionEntry { return { kind: "completion", @@ -274,6 +368,8 @@ function manifestCompletionEntry(entry: ManifestProjectionCompletion): ExposureP ...(entry.sourceCapletId ? { sourceCapletId: entry.sourceCapletId } : {}), title: entry.title, description: entry.description, + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "completion", capletId: entry.capletId }, }; @@ -289,130 +385,168 @@ function manifestCodeModeCapletEntry( ...(entry.sourceCapletId ? { sourceCapletId: entry.sourceCapletId } : {}), title: entry.title ?? entry.name, description: entry.description, + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, route: { kind: "code-mode-caplet", capletId: entry.capletId }, }; } -function progressiveCapletEntry(entry: CallableCaplet): ExposureProjectionEntry { +function entriesForCallableCaplet(entry: CallableCaplet): ExposureProjectionEntry[] { + const entries: ExposureProjectionEntry[] = []; + if (entry.exposure.progressive) entries.push(progressiveCapletEntry(entry)); + if (entry.exposure.codeMode) entries.push(codeModeCapletEntry(entry)); + if (!entry.exposure.direct) return entries; + + entries.push(...entry.tools.map((tool) => directToolEntry(entry, tool))); + if (entry.caplet.backend !== "mcp") return entries; + + entries.push(...entry.resources.map((resource) => directResourceEntry(entry, resource))); + entries.push( + ...entry.resourceTemplates.map((resourceTemplate) => + directResourceTemplateEntry(entry, resourceTemplate), + ), + ); + entries.push(...entry.prompts.map((prompt) => directPromptEntry(entry, prompt))); + if (entry.completions && (entry.resourceTemplates.length > 0 || entry.prompts.length > 0)) { + entries.push(completionEntry(entry)); + } + return entries; +} + +function progressiveCapletEntry(entry: CallableCaplet): ExposureProjectionProgressiveCaplet { const capletId = entry.caplet.server; + const inputSchema = generatedToolInputJsonSchemaForCaplet(entry.caplet); return { kind: "progressive-caplet", id: capletId, capletId, title: entry.caplet.name, - description: entry.caplet.description, - inputSchema: generatedToolInputJsonSchemaForCaplet(entry.caplet), + description: capabilityDescription(entry.caplet), + backend: entry.caplet.backend, + inputSchema, + operationNames: [...inputSchema.properties.operation.enum], + ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), + ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), route: { kind: "progressive-caplet", capletId }, }; } -function codeModeCapletEntry(entry: CallableCaplet): ExposureProjectionEntry { +function codeModeCapletEntry(entry: CallableCaplet): ExposureProjectionCodeModeCaplet { const capletId = entry.caplet.server; return { kind: "code-mode-caplet", id: capletId, capletId, title: entry.caplet.name, - description: entry.caplet.description, + description: capabilityDescription(entry.caplet), + backend: entry.caplet.backend, + ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), + ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), route: { kind: "code-mode-caplet", capletId }, }; } -function directToolEntry(entry: DirectToolRegistration): ExposureProjectionEntry { +function directToolEntry( + entry: CallableCaplet, + tool: CallableCaplet["tools"][number], +): ExposureProjectionDirectTool { + const capletId = entry.caplet.server; return { kind: "direct-tool", - id: entry.name, - capletId: entry.caplet.server, - title: entry.tool.name, - description: entry.tool.description, - inputSchema: entry.tool.inputSchema, - outputSchema: entry.tool.outputSchema, - annotations: entry.tool.annotations, + id: directToolName(capletId, tool.name), + capletId, + title: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: tool.annotations, + ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), + ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), - route: { - kind: "direct-tool", - capletId: entry.caplet.server, - downstreamName: entry.downstreamName, - }, + route: { kind: "direct-tool", capletId, downstreamName: tool.name }, }; } -function directResourceEntry(entry: DirectResourceRegistration): ExposureProjectionEntry { +function directResourceEntry( + entry: CallableCaplet, + resource: CallableCaplet["resources"][number], +): ExposureProjectionDirectResource { + const capletId = entry.caplet.server; return { kind: "direct-resource", - id: entry.uri, - capletId: entry.caplet.server, - title: entry.resource.name, - description: entry.resource.description, - ...(entry.resource.mimeType ? { mimeType: entry.resource.mimeType } : {}), - ...(typeof entry.resource.size === "number" ? { size: entry.resource.size } : {}), + id: directResourceUri(capletId, resource.uri), + capletId, + title: resource.name, + description: resource.description, + ...(resource.mimeType ? { mimeType: resource.mimeType } : {}), + ...(typeof resource.size === "number" ? { size: resource.size } : {}), + ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), + ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), - route: { - kind: "direct-resource", - capletId: entry.caplet.server, - downstreamUri: entry.downstreamUri, - }, + route: { kind: "direct-resource", capletId, downstreamUri: resource.uri }, }; } function directResourceTemplateEntry( - entry: DirectResourceTemplateRegistration, -): ExposureProjectionEntry { + entry: CallableCaplet, + resourceTemplate: CallableCaplet["resourceTemplates"][number], +): ExposureProjectionDirectResourceTemplate { + const capletId = entry.caplet.server; return { kind: "direct-resource-template", - id: entry.uriTemplate, - capletId: entry.caplet.server, - title: entry.resourceTemplate.name, - description: entry.resourceTemplate.description, - ...(entry.resourceTemplate.mimeType ? { mimeType: entry.resourceTemplate.mimeType } : {}), + id: directResourceTemplateUri(capletId, resourceTemplate.uriTemplate), + capletId, + title: resourceTemplate.name, + description: resourceTemplate.description, + ...(resourceTemplate.mimeType ? { mimeType: resourceTemplate.mimeType } : {}), + ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), + ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), route: { kind: "direct-resource-template", - capletId: entry.caplet.server, - downstreamUriTemplate: entry.downstreamUriTemplate, + capletId, + downstreamUriTemplate: resourceTemplate.uriTemplate, }, }; } -function directPromptEntry(entry: DirectPromptRegistration): ExposureProjectionEntry { - const inputSchema = { arguments: entry.prompt.arguments ?? [] }; +function directPromptEntry( + entry: CallableCaplet, + prompt: CallableCaplet["prompts"][number], +): ExposureProjectionDirectPrompt { + const capletId = entry.caplet.server; + const args = (prompt.arguments ?? []).map((argument) => ({ ...argument })); return { kind: "direct-prompt", - id: entry.name, - capletId: entry.caplet.server, - title: entry.prompt.name, - description: entry.prompt.description, - inputSchema, + id: directPromptName(capletId, prompt.name), + capletId, + title: prompt.name, + description: prompt.description, + inputSchema: { arguments: args }, + arguments: args, + ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), + ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), shadowing: shadowingPolicy(entry.caplet), - route: { - kind: "direct-prompt", - capletId: entry.caplet.server, - downstreamName: entry.downstreamName, - }, + route: { kind: "direct-prompt", capletId, downstreamName: prompt.name }, }; } -function completionEntries(snapshot: ExposureSnapshot): ExposureProjectionEntry[] { - const caplets = new Map( - [...snapshot.directPrompts, ...snapshot.directResourceTemplates].map((entry) => [ - entry.caplet.server, - entry.caplet, - ]), - ); - return [...caplets.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([capletId, caplet]) => ({ - kind: "completion" as const, - id: `${capletId}:complete`, - capletId, - title: "Complete", - description: `MCP completion for ${capletId}.`, - shadowing: shadowingPolicy(caplet), - route: { kind: "completion" as const, capletId }, - })); +function completionEntry(entry: CallableCaplet): ExposureProjectionCompletion { + const capletId = entry.caplet.server; + return { + kind: "completion", + id: `${capletId}:complete`, + capletId, + title: "Complete", + description: `MCP completion for ${capletId}.`, + ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), + ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), + shadowing: shadowingPolicy(entry.caplet), + route: { kind: "completion", capletId }, + }; } function hiddenCapletEntry(hidden: HiddenCaplet): ExposureProjectionHiddenCaplet { diff --git a/packages/core/src/generated-tool-input-schema.ts b/packages/core/src/generated-tool-input-schema.ts index c3f6a2dc..0796fae6 100644 --- a/packages/core/src/generated-tool-input-schema.ts +++ b/packages/core/src/generated-tool-input-schema.ts @@ -41,6 +41,7 @@ export const generatedToolInputDescriptions = { uri: "Exact downstream resource URI for read_resource.", ref: "Completion target reference for complete.", argument: "Completion argument object for complete.", + context: "Optional sibling completion arguments keyed by downstream argument name.", } as const; export const completionRefSchema = z.union([ @@ -52,6 +53,21 @@ export const completionArgumentSchema = z .object({ name: z.string().min(1), value: z.string() }) .strict(); +export const completionContextSchema = z + .object({ arguments: z.record(z.string(), z.string()).optional() }) + .strict(); + +export const completionContextJsonSchema = { + type: "object", + properties: { + arguments: { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + additionalProperties: false, +} as const; + const baseShape = { query: z.string().optional().describe(generatedToolInputDescriptions.query), limit: z.number().int().positive().optional().describe(generatedToolInputDescriptions.limit), @@ -83,6 +99,9 @@ export function generatedToolInputSchemaForCaplet( argument: completionArgumentSchema .optional() .describe(generatedToolInputDescriptions.argument), + context: completionContextSchema + .optional() + .describe(generatedToolInputDescriptions.context), } : {}), }) @@ -155,6 +174,10 @@ export function generatedToolInputJsonSchemaForCaplet( additionalProperties: false, description: generatedToolInputDescriptions.argument, }, + context: { + ...completionContextJsonSchema, + description: generatedToolInputDescriptions.context, + }, } : {}), }, diff --git a/packages/core/src/native/remote.ts b/packages/core/src/native/remote.ts index a972a995..7090332b 100644 --- a/packages/core/src/native/remote.ts +++ b/packages/core/src/native/remote.ts @@ -3,7 +3,11 @@ import { decodeDirectResourceUri, directResourceUriMatchesTemplate, } from "../exposure/direct-names"; -import { generatedToolInputJsonSchemaForCaplet, operations } from "../generated-tool-input-schema"; +import { + completionContextJsonSchema, + generatedToolInputJsonSchemaForCaplet, + operations, +} from "../generated-tool-input-schema"; import { buildManifestExposureProjection, type ExposureProjectionEntry, @@ -48,6 +52,8 @@ export type RemoteCapletsTool = { shadowing?: CapletShadowingPolicy | undefined; title?: string | undefined; description?: string | undefined; + useWhen?: string | undefined; + avoidWhen?: string | undefined; inputSchema?: unknown; outputSchema?: unknown; annotations?: unknown; @@ -526,6 +532,8 @@ function remoteToolToNativeTool(tool: RemoteCapletsTool): NativeCapletTool { caplet: capletId, ...(sourceCaplet && sourceCaplet !== capletId ? { sourceCaplet } : {}), ...(tool.shadowing ? { shadowing: tool.shadowing } : {}), + ...(tool.useWhen ? { useWhen: tool.useWhen } : {}), + ...(tool.avoidWhen ? { avoidWhen: tool.avoidWhen } : {}), toolName, title: tool.title ?? capletId, description: [ @@ -545,6 +553,8 @@ function remoteToolToNativeTool(tool: RemoteCapletsTool): NativeCapletTool { ...(caplet.sourceCapletId ? { sourceCapletId: caplet.sourceCapletId } : {}), name: caplet.name, description: caplet.description ?? "", + ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), + ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), shadowing: caplet.shadowing, })), } @@ -563,6 +573,8 @@ function remoteCodeModeToolDescription(tool: RemoteCapletsTool): string | undefi id: caplet.capletId, name: caplet.name, description: caplet.description ?? "", + ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), + ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), shadowing: caplet.shadowing, })), }); @@ -927,6 +939,8 @@ function progressiveToolFromProjection( title: entry.title, description: entry.description, inputSchema: entry.inputSchema, + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, ...codeModeMarker, }, @@ -948,6 +962,8 @@ function directToolFromProjection( inputSchema: entry.inputSchema, outputSchema: entry.outputSchema, annotations: entry.annotations, + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), shadowing: entry.shadowing, ...codeModeMarker, }, @@ -975,14 +991,21 @@ function primitiveToolsFromProjection( prompts: boolean; completions: boolean; shadowing: CapletShadowingPolicy; + useWhen?: string; + avoidWhen?: string; } >(); - const entryFor = (capletId: string, shadowing: CapletShadowingPolicy) => { - const existing = byCaplet.get(capletId); + const entryFor = (entry: ExposureProjectionEntry) => { + const existing = byCaplet.get(entry.capletId); if (existing) { - if (shadowing === "forbid" || (shadowing === "namespace" && existing.shadowing === "allow")) { - existing.shadowing = shadowing; + if ( + entry.shadowing === "forbid" || + (entry.shadowing === "namespace" && existing.shadowing === "allow") + ) { + existing.shadowing = entry.shadowing; } + if (!existing.useWhen && entry.useWhen) existing.useWhen = entry.useWhen; + if (!existing.avoidWhen && entry.avoidWhen) existing.avoidWhen = entry.avoidWhen; return existing; } const next = { @@ -990,19 +1013,20 @@ function primitiveToolsFromProjection( resourceTemplates: false, prompts: false, completions: false, - shadowing, + shadowing: entry.shadowing, + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), }; - byCaplet.set(capletId, next); + byCaplet.set(entry.capletId, next); return next; }; for (const entry of entries) { - if (entry.kind === "direct-resource") - entryFor(entry.capletId, entry.shadowing).resources = true; + if (entry.kind === "direct-resource") entryFor(entry).resources = true; if (entry.kind === "direct-resource-template") { - entryFor(entry.capletId, entry.shadowing).resourceTemplates = true; + entryFor(entry).resourceTemplates = true; } - if (entry.kind === "direct-prompt") entryFor(entry.capletId, entry.shadowing).prompts = true; - if (entry.kind === "completion") entryFor(entry.capletId, entry.shadowing).completions = true; + if (entry.kind === "direct-prompt") entryFor(entry).prompts = true; + if (entry.kind === "completion") entryFor(entry).completions = true; } const tools: RemoteCapletsTool[] = []; @@ -1010,26 +1034,34 @@ function primitiveToolsFromProjection( capletId: string, operation: string, shadowing: CapletShadowingPolicy, + useWhen?: string, + avoidWhen?: string, ) => { const name = `${capletId}__${operation}`; if (directToolNames.has(name)) return; - tools.push(primitiveTool(capletId, operation, shadowing, codeModeMarker)); + tools.push(primitiveTool(capletId, operation, shadowing, codeModeMarker, useWhen, avoidWhen)); }; for (const [capletId, flags] of byCaplet) { if (flags.resources) { - addPrimitiveTool(capletId, "list_resources", flags.shadowing); - addPrimitiveTool(capletId, "read_resource", flags.shadowing); + addPrimitiveTool(capletId, "list_resources", flags.shadowing, flags.useWhen, flags.avoidWhen); + addPrimitiveTool(capletId, "read_resource", flags.shadowing, flags.useWhen, flags.avoidWhen); } if (flags.resourceTemplates) { - addPrimitiveTool(capletId, "list_resource_templates", flags.shadowing); - addPrimitiveTool(capletId, "read_resource", flags.shadowing); + addPrimitiveTool( + capletId, + "list_resource_templates", + flags.shadowing, + flags.useWhen, + flags.avoidWhen, + ); + addPrimitiveTool(capletId, "read_resource", flags.shadowing, flags.useWhen, flags.avoidWhen); } if (flags.prompts) { - addPrimitiveTool(capletId, "list_prompts", flags.shadowing); - addPrimitiveTool(capletId, "get_prompt", flags.shadowing); + addPrimitiveTool(capletId, "list_prompts", flags.shadowing, flags.useWhen, flags.avoidWhen); + addPrimitiveTool(capletId, "get_prompt", flags.shadowing, flags.useWhen, flags.avoidWhen); } if (flags.completions) { - addPrimitiveTool(capletId, "complete", flags.shadowing); + addPrimitiveTool(capletId, "complete", flags.shadowing, flags.useWhen, flags.avoidWhen); } } return [...new Map(tools.map((tool) => [tool.name, tool])).values()]; @@ -1040,6 +1072,8 @@ function primitiveTool( operation: string, shadowing: CapletShadowingPolicy, codeModeMarker: Pick | Record, + useWhen?: string, + avoidWhen?: string, ): RemoteCapletsTool { return { name: `${capletId}__${operation}`, @@ -1048,6 +1082,8 @@ function primitiveTool( title: operation, description: `MCP ${operation.replace(/_/g, " ")}.`, inputSchema: primitiveInputSchema(operation), + ...(useWhen ? { useWhen } : {}), + ...(avoidWhen ? { avoidWhen } : {}), shadowing, ...codeModeMarker, }; @@ -1087,6 +1123,7 @@ function primitiveInputSchema(operation: string): Record { properties: { ref: { type: "object", additionalProperties: true }, argument: { type: "object", additionalProperties: true }, + context: completionContextJsonSchema, }, required: ["ref", "argument"], additionalProperties: false, diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index 207f737c..05af5e25 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -17,7 +17,7 @@ import { type RemoteCapletsClient, type SdkRemoteCapletsClientOptions, } from "./remote"; -import { CapletsEngine } from "../engine"; +import { CapletsEngine, type ResolvedExposureProjection } from "../engine"; import { CapletsError, errorResult } from "../errors"; import type { RuntimeMode, @@ -35,26 +35,24 @@ import { type RuntimeTelemetryContext, } from "../telemetry"; import { - nativeCapletPromptGuidance, - nativeCapletToolDescription, nativeCapletToolName, nativeCodeModePromptGuidance, nativeCodeModeToolId, nativeCodeModeToolName, } from "./tools"; import { nativeDirectToolName } from "../exposure/direct-names"; +import { completionContextJsonSchema } from "../generated-tool-input-schema"; import type { NamespaceDiagnostic } from "../exposure/namespace"; -import { resolveExposure } from "../exposure/policy"; import { - buildExposureProjection, resolveNativeProjectionMerge, type ExposureProjection, + type ExposureProjectionCodeModeCaplet, + type ExposureProjectionProgressiveCaplet, } from "../exposure/projection"; import { generateCodeModeDeclarations, generateCodeModeRunToolDescription, } from "../code-mode/declarations"; -import type { DirectToolRegistration, ExposureSnapshot } from "../exposure/discovery"; import type { ProjectBindingExecutionContext } from "../project-binding/execution-context"; import { runCodeMode } from "../code-mode/runner"; import { CodeModeSessionManager } from "../code-mode/sessions"; @@ -75,7 +73,6 @@ import { resolveConfigPath, resolveProjectConfigPath, } from "../config"; -import { generatedToolInputJsonSchemaForCaplet } from "../generated-tool-input-schema"; import type { CapletsRemoteAuth } from "../remote/options"; import { resolveRemoteSelection, type ResolvedRemoteSelection } from "../remote/selection"; @@ -117,7 +114,7 @@ export type NativeCapletTool = { useWhen?: string; avoidWhen?: string; promptGuidance: string[]; - inputSchema?: ReturnType | Record; + inputSchema?: Record; outputSchema?: Record; annotations?: Record; operationNames?: string[]; @@ -193,12 +190,15 @@ class DefaultNativeCapletsService implements NativeCapletsService { private readonly writeErr: (value: string) => void; private readonly unsubscribeEngineReload: () => void; private readonly toolListeners = new Set(); - private directToolRoutes = new Map(); - private exposureSnapshot: ExposureSnapshot | undefined; - private exposureProjection: ExposureProjection | undefined; + private directToolRoutes = new Map< + string, + { capletId: string; operationName: string; generation: number } + >(); + private capletRoutes = new Map(); + private resolvedProjection: ResolvedExposureProjection | undefined; private readonly codeModeSessions = new CodeModeSessionManager(); private postReloadRefresh: Promise | undefined; - private exposureRefreshGeneration = 0; + private exposureRefreshSequence = 0; constructor(options: LocalNativeCapletsServiceOptions) { this.writeErr = options.writeErr ?? (() => undefined); @@ -211,89 +211,55 @@ class DefaultNativeCapletsService implements NativeCapletsService { telemetryRuntimeMode: options.telemetryRuntimeMode ?? runtimeModeFromNativeOptions(options), telemetryIntegration: options.telemetryIntegration ?? "native", }); - this.postReloadRefresh = this.refreshExposureSnapshot({ - emitToolsChanged: this.hasSnapshotBackedDirectExposure(), - }); + this.postReloadRefresh = this.refreshExposureProjection({ emitToolsChanged: true }); this.unsubscribeEngineReload = this.engine.onReload(() => { - this.postReloadRefresh = this.refreshExposureSnapshot({ emitToolsChanged: true }); + this.emitToolsChanged(); + this.postReloadRefresh = this.refreshExposureProjection({ emitToolsChanged: true }); }); } listTools(): NativeCapletTool[] { this.directToolRoutes = new Map(); - if (this.exposureSnapshot && this.exposureProjection) { - return this.projectedNativeTools(this.exposureProjection, this.exposureSnapshot); - } - const progressiveTools: NativeCapletTool[] = []; - const codeModeCaplets: NativeCapletTool[] = []; - const directTools: NativeCapletTool[] = []; - const caplets = this.engine.enabledServers().filter((caplet) => { - if (caplet.setup) return false; - if (caplet.projectBinding?.required && !this.engine.currentProjectBindingContext()) { - return false; - } - return true; - }); - for (const caplet of caplets) { - const exposure = resolveExposure( - caplet.exposure, - this.engine.currentConfig().options.exposure, - ); - if (exposure.progressive) { - const tool = progressiveNativeTool(caplet); - progressiveTools.push(tool); - if (exposure.codeMode) codeModeCaplets.push(codeModeCapletDescriptor(caplet)); - continue; - } - if (exposure.direct) { - directTools.push(...this.directNativeTools(caplet, this.exposureSnapshot)); - } - if (exposure.codeMode) { - codeModeCaplets.push(codeModeCapletDescriptor(caplet)); - } - } - return [ - ...progressiveTools, - ...directTools, - ...(codeModeCaplets.length > 0 ? [codeModeRunNativeTool(codeModeCaplets)] : []), - ]; + this.capletRoutes = new Map(); + const resolved = this.resolvedProjection; + if (!resolved || resolved.generation !== this.engine.currentExposureGeneration()) return []; + return this.projectedNativeTools(resolved.projection, resolved.generation); } private projectedNativeTools( projection: ExposureProjection, - snapshot: ExposureSnapshot, + generation: number, ): NativeCapletTool[] { const progressiveTools: NativeCapletTool[] = []; const directTools: NativeCapletTool[] = []; const codeModeCaplets: NativeCapletTool[] = []; - const callableById = new Map( - snapshot.callableCaplets.map((entry) => [entry.caplet.server, entry]), - ); - const directToolsByRoute = new Map( - snapshot.directTools.map((entry) => [ - directToolRouteKey(entry.caplet.server, entry.downstreamName), - entry, - ]), - ); const primitiveCapletIds = new Set(); for (const entry of projection.entries) { - const callable = callableById.get(entry.capletId); - if (!callable) continue; if (entry.kind === "progressive-caplet") { - progressiveTools.push(progressiveNativeTool(callable.caplet)); + progressiveTools.push(progressiveNativeTool(entry)); + this.capletRoutes.set(entry.id, { capletId: entry.route.capletId, generation }); } if (entry.kind === "code-mode-caplet") { - codeModeCaplets.push(codeModeCapletDescriptor(callable.caplet)); + codeModeCaplets.push(codeModeCapletDescriptor(entry)); } if (entry.kind === "direct-tool") { - const direct = - entry.route.kind === "direct-tool" - ? directToolsByRoute.get( - directToolRouteKey(entry.route.capletId, entry.route.downstreamName), - ) - : undefined; - if (direct) directTools.push(this.directDiscoveredTool(callable.caplet, direct, entry.id)); + const inputSchema = asRecord(entry.inputSchema); + const outputSchema = asRecord(entry.outputSchema); + const annotations = asRecord(entry.annotations); + directTools.push( + this.directNativeTool(entry.route.capletId, entry.route.downstreamName, generation, { + visibleId: entry.id, + ...(entry.title ? { title: entry.title } : {}), + ...(entry.description ? { description: entry.description } : {}), + ...(inputSchema ? { inputSchema } : {}), + ...(outputSchema ? { outputSchema } : {}), + ...(annotations ? { annotations } : {}), + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), + shadowing: entry.shadowing, + }), + ); } if ( entry.kind === "direct-resource" || @@ -306,13 +272,13 @@ class DefaultNativeCapletsService implements NativeCapletsService { } for (const capletId of [...primitiveCapletIds].sort()) { - const callable = callableById.get(capletId); - if (!callable || callable.caplet.backend !== "mcp") continue; + const metadata = nativeProjectionMetadata(capletId, projection); directTools.push( - ...mcpPrimitiveNativeTools(callable.caplet, snapshot).map((operationName) => - this.directNativeTool(callable.caplet, operationName, { + ...mcpPrimitiveNativeTools(capletId, projection).map((operationName) => + this.directNativeTool(capletId, operationName, generation, { description: `MCP ${operationName.replace(/_/g, " ")}.`, inputSchema: nativeMcpPrimitiveInputSchema(operationName), + ...metadata, }), ), ); @@ -326,7 +292,12 @@ class DefaultNativeCapletsService implements NativeCapletsService { } async execute(capletId: string, request: unknown): Promise { + this.assertResolvedProjection(); + this.listTools(); if (capletId === nativeCodeModeToolId) { + if (this.codeModeNativeTools().length === 0) { + throw new CapletsError("REQUEST_INVALID", "Code Mode has no callable Caplets."); + } const started = Date.now(); const envelope = await executeCodeModeRunNative( this.codeModeDelegate(), @@ -344,8 +315,14 @@ class DefaultNativeCapletsService implements NativeCapletsService { .catch(() => undefined); return envelope; } + const capletRoute = this.capletRoutes.get(capletId); + if (capletRoute) { + this.assertRouteGeneration(capletRoute.generation); + return await this.engine.execute(capletRoute.capletId, request); + } const route = this.directToolRoutes.get(capletId); if (route) { + this.assertRouteGeneration(route.generation); if (isMcpPrimitiveRoute(route.operationName)) { return await this.engine.execute( route.capletId, @@ -358,7 +335,7 @@ class DefaultNativeCapletsService implements NativeCapletsService { isRecord(request) ? request : {}, ); } - return await this.engine.execute(capletId, request); + return errorResult(new Error(`server not found: ${capletId}`)); } async captureCodeModeOutcome( @@ -392,132 +369,77 @@ class DefaultNativeCapletsService implements NativeCapletsService { await this.engine.close(); } - private directNativeTools( - caplet: ReturnType[number], - snapshot: ExposureSnapshot | undefined, - ): NativeCapletTool[] { - if (caplet.backend === "http") { - return Object.entries(caplet.actions) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([operationName, action]) => - this.directNativeTool(caplet, operationName, { - ...(action.description ? { description: action.description } : {}), - ...(action.inputSchema ? { inputSchema: action.inputSchema } : {}), - ...(action.outputSchema ? { outputSchema: action.outputSchema } : {}), - annotations: { - readOnlyHint: action.method === "GET", - destructiveHint: action.method === "DELETE", - }, - }), - ); - } - if (caplet.backend === "cli") { - return Object.entries(caplet.actions) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([operationName, action]) => - this.directNativeTool(caplet, operationName, { - ...(action.description ? { description: action.description } : {}), - ...(action.inputSchema ? { inputSchema: action.inputSchema } : {}), - ...(action.outputSchema ? { outputSchema: action.outputSchema } : {}), - ...(action.annotations ? { annotations: action.annotations } : {}), - }), - ); - } - if (caplet.backend === "mcp") { - const directTools = - snapshot?.directTools - .filter((entry) => entry.caplet.server === caplet.server) - .map((entry) => this.directDiscoveredTool(caplet, entry)) ?? []; - return [ - ...directTools, - ...mcpPrimitiveNativeTools(caplet, snapshot).map((operationName) => - this.directNativeTool(caplet, operationName, { - description: `MCP ${operationName.replace(/_/g, " ")}.`, - inputSchema: nativeMcpPrimitiveInputSchema(operationName), - }), - ), - ]; - } - return ( - snapshot?.directTools - .filter((entry) => entry.caplet.server === caplet.server) - .map((entry) => this.directDiscoveredTool(caplet, entry)) ?? [] - ); - } - - private directDiscoveredTool( - caplet: ReturnType[number], - entry: DirectToolRegistration, - visibleId?: string, - ): NativeCapletTool { - return this.directNativeTool(caplet, entry.downstreamName, { - ...(visibleId ? { visibleId } : {}), - ...(entry.tool.description ? { description: entry.tool.description } : {}), - ...(entry.tool.inputSchema - ? { inputSchema: entry.tool.inputSchema as Record } - : {}), - ...(entry.tool.outputSchema - ? { outputSchema: entry.tool.outputSchema as Record } - : {}), - ...(entry.tool.annotations ? { annotations: entry.tool.annotations } : {}), - }); - } - private directNativeTool( - caplet: ReturnType[number], + capletId: string, operationName: string, + generation: number, options: { visibleId?: string; + title?: string; description?: string; inputSchema?: Record; outputSchema?: Record; annotations?: Record; + useWhen?: string; + avoidWhen?: string; + shadowing?: CapletShadowingPolicy; }, ): NativeCapletTool { - const routeId = options.visibleId ?? `${caplet.server}__${operationName}`; - const toolName = nativeDirectToolName(caplet.server, operationName); - this.directToolRoutes.set(routeId, { capletId: caplet.server, operationName }); + const routeId = options.visibleId ?? `${capletId}__${operationName}`; + const toolName = nativeDirectToolName(capletId, operationName); + this.directToolRoutes.set(routeId, { capletId, operationName, generation }); return { caplet: routeId, - sourceCaplet: caplet.server, + sourceCaplet: capletId, toolName, - title: operationName, + title: options.title ?? operationName, description: options.description ?? "", - ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), - ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), - promptGuidance: [`Use ${toolName} for ${caplet.name} ${operationName}.`], + promptGuidance: [`Use ${toolName} for ${capletId} ${operationName}.`], ...(options.inputSchema ? { inputSchema: options.inputSchema } : {}), ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), ...(options.annotations ? { annotations: options.annotations } : {}), + ...(options.useWhen ? { useWhen: options.useWhen } : {}), + ...(options.avoidWhen ? { avoidWhen: options.avoidWhen } : {}), + shadowing: options.shadowing ?? "forbid", }; } - private async refreshExposureSnapshot(options: { emitToolsChanged: boolean }): Promise { - const generation = ++this.exposureRefreshGeneration; + private async refreshExposureProjection(options: { emitToolsChanged: boolean }): Promise { + const sequence = ++this.exposureRefreshSequence; try { - const snapshot = await this.engine.exposureSnapshot({ + const resolved = await this.engine.exposureProjection({ discoverNonDirectMcpSurfaces: false, }); - if (generation !== this.exposureRefreshGeneration) return; - this.exposureSnapshot = snapshot; - this.exposureProjection = buildExposureProjection(snapshot); + if ( + sequence !== this.exposureRefreshSequence || + resolved.generation !== this.engine.currentExposureGeneration() + ) { + return; + } + this.resolvedProjection = resolved; if (options.emitToolsChanged) this.emitToolsChanged(); } catch (error) { - if (generation !== this.exposureRefreshGeneration) return; + if (sequence !== this.exposureRefreshSequence) return; this.writeErr(`Caplets native tool reload failed.\n`); this.writeErr(`${error instanceof Error ? error.message : String(error)}\n`); } } - private hasSnapshotBackedDirectExposure(): boolean { - return this.engine.enabledServers().some((caplet) => { - if (caplet.setup) return false; - if (caplet.projectBinding?.required && !this.engine.currentProjectBindingContext()) { - return false; - } - if (caplet.backend === "http" || caplet.backend === "cli") return false; - return resolveExposure(caplet.exposure, this.engine.currentConfig().options.exposure).direct; - }); + private assertResolvedProjection(): ResolvedExposureProjection { + const resolved = this.resolvedProjection; + if (resolved && resolved.generation === this.engine.currentExposureGeneration()) { + return resolved; + } + throw new CapletsError( + "SERVER_UNAVAILABLE", + "Caplets exposure is unavailable until the current projection resolves.", + ); + } + + private assertRouteGeneration(generation: number): void { + const resolved = this.assertResolvedProjection(); + if (resolved.generation === generation) return; + throw new CapletsError("SERVER_UNAVAILABLE", "Caplets exposure route is stale."); } private emitToolsChanged(): void { @@ -535,7 +457,7 @@ class DefaultNativeCapletsService implements NativeCapletsService { private codeModeDelegate(): NativeCapletsService { return { listTools: () => this.codeModeNativeTools(), - execute: async (capletId, request) => await this.engine.execute(capletId, request), + execute: async (capletId, request) => await this.executeCodeModeCaplet(capletId, request), reload: async () => await this.reload(), onToolsChanged: () => () => undefined, close: async () => undefined, @@ -543,75 +465,122 @@ class DefaultNativeCapletsService implements NativeCapletsService { } private codeModeNativeTools(): NativeCapletTool[] { - const snapshotCaplets = this.exposureSnapshot?.codeModeCaplets.map((entry) => entry.caplet); - const caplets = - snapshotCaplets ?? - this.engine.enabledServers().filter((caplet) => { - if (caplet.setup) return false; - if (caplet.projectBinding?.required && !this.engine.currentProjectBindingContext()) { - return false; - } - return resolveExposure(caplet.exposure, this.engine.currentConfig().options.exposure) - .codeMode; - }); - return caplets.map(codeModeCapletDescriptor); + const resolved = this.resolvedProjection; + if (!resolved || resolved.generation !== this.engine.currentExposureGeneration()) return []; + return resolved.projection.entries + .filter( + (entry): entry is ExposureProjectionCodeModeCaplet => entry.kind === "code-mode-caplet", + ) + .map(codeModeCapletDescriptor); + } + + private async executeCodeModeCaplet(capletId: string, request: unknown): Promise { + const caplets = this.codeModeNativeTools(); + if (!caplets.some((caplet) => caplet.caplet === capletId)) { + throw new CapletsError("REQUEST_INVALID", `Caplet ${capletId} is not callable in Code Mode.`); + } + return await this.engine.execute(capletId, request); } } -function progressiveNativeTool( - caplet: ReturnType[number], -): NativeCapletTool { - const toolName = nativeCapletToolName(caplet.server); - const inputSchema = generatedToolInputJsonSchemaForCaplet(caplet); +function progressiveNativeTool(entry: ExposureProjectionProgressiveCaplet): NativeCapletTool { + const toolName = nativeCapletToolName(entry.id); + const inputSchema = asRecord(entry.inputSchema); return { - caplet: caplet.server, + caplet: entry.id, + ...(entry.sourceCapletId ? { sourceCaplet: entry.sourceCapletId } : {}), toolName, - title: caplet.name, - description: nativeCapletToolDescription(toolName, caplet), - ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), - ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), - promptGuidance: nativeCapletPromptGuidance(toolName, caplet), - inputSchema, - operationNames: [...inputSchema.properties.operation.enum], + title: entry.title ?? entry.id, + description: projectionNativeDescription(toolName, entry), + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), + promptGuidance: projectionNativePromptGuidance(toolName, entry), + ...(inputSchema ? { inputSchema } : {}), + ...(entry.operationNames ? { operationNames: [...entry.operationNames] } : {}), + shadowing: entry.shadowing, }; } -function codeModeCapletDescriptor( - caplet: ReturnType[number], -): NativeCapletTool { - const toolName = nativeCapletToolName(caplet.server); +function codeModeCapletDescriptor(entry: ExposureProjectionCodeModeCaplet): NativeCapletTool { + const toolName = nativeCapletToolName(entry.id); return { - caplet: caplet.server, + caplet: entry.id, + ...(entry.sourceCapletId ? { sourceCaplet: entry.sourceCapletId } : {}), toolName, - title: caplet.name, - description: nativeCapletToolDescription(toolName, caplet), - ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), - ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), - promptGuidance: nativeCapletPromptGuidance(toolName, caplet), + title: entry.title ?? entry.id, + description: projectionNativeDescription(toolName, entry), + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), + promptGuidance: projectionNativePromptGuidance(toolName, entry), + shadowing: entry.shadowing, }; } -function directToolRouteKey(capletId: string, downstreamName: string): string { - return `${capletId}:${downstreamName}`; +function projectionNativeDescription( + toolName: string, + entry: ExposureProjectionProgressiveCaplet | ExposureProjectionCodeModeCaplet, +): string { + return [ + entry.description ?? "", + "Use tools/search_tools to find downstream names, arg hints, and callTemplate. Call call_tool directly from callTemplate/argsTemplate for simple calls; reserve describe_tool for exact schemas, nested args, fields, or uncertainty. call_tool.args must match inputSchema exactly. Do not guess tool names or schemas. Prefer read/search/list tools for triage.", + "", + `Native tool name: ${toolName}`, + `Original Caplet ID: ${entry.id}`, + ].join("\n"); } -function mcpPrimitiveNativeTools( - caplet: ReturnType[number], - snapshot: ExposureSnapshot | undefined, +function projectionNativePromptGuidance( + toolName: string, + entry: ExposureProjectionProgressiveCaplet | ExposureProjectionCodeModeCaplet, ): string[] { - const operations = []; - if (snapshot?.directResources.some((entry) => entry.caplet.server === caplet.server)) { - operations.push("list_resources", "read_resource"); - } - if (snapshot?.directResourceTemplates.some((entry) => entry.caplet.server === caplet.server)) { + const descriptorFirst = + "Use tools/search_tools callTemplate/arg hints for simple calls; reserve describe_tool for exact schemas, nested args, fields, or uncertainty. call_tool.args must match inputSchema exactly. Do not guess tool names or schemas."; + return entry.backend === "mcp" + ? [ + `Use ${toolName} for the ${entry.title ?? entry.id} Caplet capability domain.`, + "Prefer resources for readable context, prompts for reusable workflows, and tools for actions.", + descriptorFirst, + ] + : [ + `Use ${toolName} for the ${entry.title ?? entry.id} Caplet capability domain.`, + descriptorFirst, + ]; +} + +function nativeProjectionMetadata( + capletId: string, + projection: ExposureProjection, +): { + useWhen?: string; + avoidWhen?: string; + shadowing: CapletShadowingPolicy; +} { + const entries = projection.entries.filter((entry) => entry.capletId === capletId); + const useWhen = entries.find((entry) => entry.useWhen)?.useWhen; + const avoidWhen = entries.find((entry) => entry.avoidWhen)?.avoidWhen; + const shadowing = entries.some((entry) => entry.shadowing === "forbid") + ? "forbid" + : entries.some((entry) => entry.shadowing === "namespace") + ? "namespace" + : "allow"; + return { + ...(useWhen ? { useWhen } : {}), + ...(avoidWhen ? { avoidWhen } : {}), + shadowing, + }; +} + +function mcpPrimitiveNativeTools(capletId: string, projection: ExposureProjection): string[] { + const kinds = new Set( + projection.entries.filter((entry) => entry.capletId === capletId).map((entry) => entry.kind), + ); + const operations: string[] = []; + if (kinds.has("direct-resource")) operations.push("list_resources", "read_resource"); + if (kinds.has("direct-resource-template")) { operations.push("list_resource_templates", "read_resource"); } - if (snapshot?.directPrompts.some((entry) => entry.caplet.server === caplet.server)) { - operations.push("list_prompts", "get_prompt", "complete"); - } - if (snapshot?.directResourceTemplates.some((entry) => entry.caplet.server === caplet.server)) { - operations.push("complete"); - } + if (kinds.has("direct-prompt")) operations.push("list_prompts", "get_prompt"); + if (kinds.has("completion")) operations.push("complete"); return [...new Set(operations)]; } @@ -641,6 +610,7 @@ function nativeMcpPrimitiveInputSchema(operationName: string): Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function asRecord(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + function runtimeModeFromNativeOptions(options: NativeCapletsServiceOptions) { if (options.mode === "local") return "local"; if (options.mode === "remote") return "remote"; diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 20fda8cd..f1e15ade 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1,7 +1,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport"; import type { CapletsConfig } from "./config"; import { CapletsEngine, type CapletsEngineOptions } from "./engine"; -import { CapletsMcpSession, type ToolServer } from "./serve/session"; +import { CapletsMcpSession, type CapletsMcpSessionOptions, type ToolServer } from "./serve/session"; type CapletsRuntimeOptions = { configPath?: string; @@ -36,7 +36,12 @@ export class CapletsRuntime { } async reload(): Promise { - return await this.engine.reload(); + const previousGeneration = this.engine.currentExposureGeneration(); + const reloaded = await this.engine.reload(); + if (this.engine.currentExposureGeneration() !== previousGeneration) { + await this.session.waitForReloadRefresh().catch(() => undefined); + } + return reloaded; } async close(): Promise { @@ -60,8 +65,11 @@ export class CapletsRuntime { } } -function selectSessionOptions(options: CapletsRuntimeOptions): { server?: ToolServer } { - return options.server === undefined ? {} : { server: options.server }; +function selectSessionOptions(options: CapletsRuntimeOptions): CapletsMcpSessionOptions { + return { + ...(options.server === undefined ? {} : { server: options.server }), + ...(options.writeErr === undefined ? {} : { writeErr: options.writeErr }), + }; } function engineOptions(options: CapletsRuntimeOptions): CapletsEngineOptions { diff --git a/packages/core/src/serve/session.ts b/packages/core/src/serve/session.ts index ab6415d4..c3ad1e19 100644 --- a/packages/core/src/serve/session.ts +++ b/packages/core/src/serve/session.ts @@ -7,10 +7,11 @@ import { type RegisteredResourceTemplate, type RegisteredTool, } from "@modelcontextprotocol/sdk/server/mcp"; +import { completable } from "@modelcontextprotocol/sdk/server/completable"; +import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport"; import { z } from "zod"; import { version as packageJsonVersion } from "../../package.json"; -import type { CapletConfig, CapletsConfig } from "../config"; import { generateCodeModeDeclarations, generateCodeModeRunToolDescription, @@ -24,28 +25,21 @@ import { codeModeRunParamsSchema, emptyCodeModeRunMeta, } from "../code-mode/tool"; -import type { CapletsEngine } from "../engine"; +import type { CapletsEngine, ResolvedExposureProjection } from "../engine"; +import { CapletsError } from "../errors"; import type { - CallableCaplet, - DirectPromptRegistration, - DirectResourceRegistration, - DirectResourceTemplateRegistration, - DirectToolRegistration, - ExposureSnapshot, -} from "../exposure/discovery"; -import { buildExposureProjection, type ExposureProjection } from "../exposure/projection"; + ExposureProjection, + ExposureProjectionCodeModeCaplet, + ExposureProjectionDirectPrompt, + ExposureProjectionDirectResource, + ExposureProjectionDirectResourceTemplate, + ExposureProjectionDirectTool, + ExposureProjectionProgressiveCaplet, + ExposureProjectionPromptArgument, +} from "../exposure/projection"; import { decodeDirectResourceUri } from "../exposure/direct-names"; -import { resolveExposure } from "../exposure/policy"; -import { generatedToolInputSchemaForCaplet } from "../generated-tool-input-schema"; import type { NativeCapletTool, NativeCapletsService } from "../native/service"; -import { projectBindingMissingContextDiagnostic } from "../project-binding/errors"; -import type { ProjectBindingExecutionContext } from "../project-binding/execution-context"; -import { - nativeCapletPromptGuidance, - nativeCapletToolDescription, - nativeCapletToolName, -} from "../native/tools"; -import { capabilityDescription } from "../registry"; +import { nativeCapletToolName } from "../native/tools"; const directToolSchemaAjv = new Ajv({ allErrors: true, @@ -60,6 +54,7 @@ export type ToolServer = Pick & export type CapletsMcpSessionOptions = { server?: ToolServer; + writeErr?: ((value: string) => void) | undefined; }; type ToolRegistrationPlan = { @@ -67,6 +62,11 @@ type ToolRegistrationPlan = { update(tool: RegisteredTool): void; }; +type ExposureProjectionBinding = { + generation: number; + epoch: number; +}; + export class CapletsMcpSession { readonly server: ToolServer; private readonly tools = new Map(); @@ -75,7 +75,12 @@ export class CapletsMcpSession { private codeModeTool: RegisteredTool | undefined; private readonly codeModeSessions = new CodeModeSessionManager(); private readonly unsubscribeReload: () => void; + private resolvedProjection: ResolvedExposureProjection | undefined; + private refreshSequence = 0; + private projectionEpoch = 0; + private reloadRefresh: Promise = Promise.resolve(); private closed = false; + private readonly writeErr: (value: string) => void; constructor( private readonly engine: CapletsEngine, @@ -87,27 +92,23 @@ export class CapletsMcpSession { name: "caplets", version: packageJsonVersion, }); - this.unsubscribeReload = this.engine.onReload(({ previous, next }) => { - this.reconcileFromSnapshot( - staticExposureSnapshot( - next, - this.engine.enabledServers(), - this.engine.currentProjectBindingContext(), - ), - ); - void this.refreshExposure(previous, next); + this.writeErr = + options.writeErr ?? + ((value) => { + process.stderr.write(value); + }); + this.unsubscribeReload = this.engine.onReload(() => { + this.reloadRefresh = this.refreshExposure(); + void this.reloadRefresh.catch((error) => { + this.writeErr( + `Caplets exposure refresh failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + }); }); - this.reconcileFromSnapshot( - staticExposureSnapshot( - this.engine.currentConfig(), - this.engine.enabledServers(), - this.engine.currentProjectBindingContext(), - ), - ); } async connect(transport: Transport): Promise { - await this.refreshExposure(undefined, this.engine.currentConfig()); + await this.refreshExposure(); await this.server.connect(transport); } @@ -115,12 +116,35 @@ export class CapletsMcpSession { return [...this.tools.keys()].sort(); } - async refreshExposure( - _previous: CapletsConfig | undefined = undefined, - _next: CapletsConfig = this.engine.currentConfig(), - ): Promise { + async refreshExposure(): Promise { if (this.closed) return; - this.reconcileFromSnapshot(await this.engine.exposureSnapshot()); + const sequence = ++this.refreshSequence; + const resolved = await this.engine.exposureProjection({ + discoverNonDirectMcpSurfaces: false, + }); + if ( + this.closed || + sequence !== this.refreshSequence || + resolved.generation !== this.engine.currentExposureGeneration() + ) { + return; + } + const binding = { + generation: resolved.generation, + epoch: ++this.projectionEpoch, + }; + try { + this.reconcileFromProjection(resolved.projection, binding); + this.resolvedProjection = resolved; + } catch (error) { + this.resolvedProjection = undefined; + this.clearRegistrations(); + throw error; + } + } + + async waitForReloadRefresh(): Promise { + await this.reloadRefresh; } async close(): Promise { @@ -132,88 +156,77 @@ export class CapletsMcpSession { await this.server.close(); } - private reconcileFromSnapshot(snapshot: ExposureSnapshot): void { - this.reconcileFromProjection(buildExposureProjection(snapshot), snapshot); - } - private reconcileFromProjection( projection: ExposureProjection, - snapshot: ExposureSnapshot, + binding: ExposureProjectionBinding, ): void { - const codeModeCaplets = projection.entries - .filter((entry) => entry.kind === "code-mode-caplet") - .flatMap((entry) => { - const caplet = snapshot.codeModeCaplets.find( - (candidate) => candidate.caplet.server === entry.capletId, - ); - return caplet ? [caplet] : []; - }); + const codeModeCaplets = projection.entries.filter( + (entry): entry is ExposureProjectionCodeModeCaplet => entry.kind === "code-mode-caplet", + ); + const completionCapletIds = new Set( + projection.entries + .filter((entry) => entry.kind === "completion") + .map((entry) => entry.route.capletId), + ); if (codeModeCaplets.length > 0) { + const callback = async (request: unknown) => + this.handleCodeModeRunTool(request, binding, codeModeCaplets); if (this.codeModeTool) { this.codeModeTool.update({ title: "Code Mode", description: codeModeRunToolDescription(codeModeCaplets), paramsSchema: codeModeRunParamsSchema, - callback: async (request: unknown) => this.handleCodeModeRunTool(request), + callback, enabled: true, }); } else { - this.codeModeTool = this.registerCodeModeTool(codeModeCaplets); + this.codeModeTool = this.registerCodeModeTool(codeModeCaplets, binding); } } else if (this.codeModeTool) { this.codeModeTool.remove(); this.codeModeTool = undefined; } - const progressiveById = new Map( - snapshot.progressiveCaplets.map((entry) => [entry.caplet.server, entry]), - ); - const directToolsById = new Map(snapshot.directTools.map((entry) => [entry.name, entry])); const desiredTools = new Map(); for (const entry of projection.entries) { if (entry.kind === "progressive-caplet") { - const capletEntry = progressiveById.get(entry.id); - if (!capletEntry) continue; + const inputSchema = zodSchemaForDirectTool(entry.inputSchema); desiredTools.set(entry.id, { - register: () => this.registerCapletTool(capletEntry.caplet), + register: () => this.registerCapletTool(entry, binding), update: (tool) => (tool.update as (updates: Record) => void)({ - title: capletEntry.caplet.name, - description: capabilityDescription(capletEntry.caplet), - paramsSchema: generatedToolInputSchemaForCaplet(capletEntry.caplet).shape, - callback: async (request: unknown) => - this.engine.execute(capletEntry.caplet.server, request) as never, + title: entry.title, + description: entry.description, + paramsSchema: inputSchema?.shape, + callback: async (request: unknown) => { + this.assertProjectionBinding(binding); + return this.engine.execute(entry.route.capletId, request) as never; + }, enabled: true, }), }); } if (entry.kind === "direct-tool") { - const directTool = directToolsById.get(entry.id); - if (!directTool) continue; - const inputSchema = zodSchemaForDirectTool(directTool.tool.inputSchema); - const outputSchema = zodSchemaForDirectTool(directTool.tool.outputSchema); + const inputSchema = zodSchemaForDirectTool(entry.inputSchema); + const outputSchema = zodSchemaForDirectTool(entry.outputSchema); desiredTools.set(entry.id, { - register: () => this.registerDirectTool(directTool), + register: () => this.registerDirectTool(entry, binding), update: (tool) => (tool.update as (updates: Record) => void)({ - title: directTool.tool.name, - description: directTool.tool.description, + title: entry.title, + description: entry.description, paramsSchema: inputSchema, outputSchema, - annotations: directTool.tool.annotations, - _meta: { - caplets: { - capletId: directTool.caplet.server, - downstreamName: directTool.downstreamName, - exposure: "direct", - }, - }, - callback: async (request: unknown) => - this.engine.executeDirectTool( - directTool.caplet.server, - directTool.downstreamName, + annotations: entry.annotations, + _meta: directToolMetadata(entry), + callback: async (request: unknown) => { + this.assertProjectionBinding(binding); + return this.engine.executeDirectTool( + entry.route.capletId, + entry.route.downstreamName, isRecord(request) ? request : {}, - ) as never, + ) as never; + }, enabled: true, }), }); @@ -238,23 +251,25 @@ export class CapletsMcpSession { for (const prompt of this.prompts.values()) prompt.remove(); this.resources.clear(); this.prompts.clear(); - const resourcesById = new Map(snapshot.directResources.map((entry) => [entry.uri, entry])); - const resourceTemplatesById = new Map( - snapshot.directResourceTemplates.map((entry) => [entry.uriTemplate, entry]), - ); - const promptsById = new Map(snapshot.directPrompts.map((entry) => [entry.name, entry])); for (const entry of projection.entries) { if (entry.kind === "direct-resource") { - const resource = resourcesById.get(entry.id); - if (resource) this.resources.set(entry.id, this.registerDirectResource(resource)); + this.resources.set(entry.id, this.registerDirectResource(entry, binding)); } if (entry.kind === "direct-resource-template") { - const template = resourceTemplatesById.get(entry.id); - if (template) this.resources.set(entry.id, this.registerDirectResourceTemplate(template)); + this.resources.set( + entry.id, + this.registerDirectResourceTemplate( + entry, + binding, + completionCapletIds.has(entry.route.capletId), + ), + ); } if (entry.kind === "direct-prompt") { - const prompt = promptsById.get(entry.id); - if (prompt) this.prompts.set(entry.id, this.registerDirectPrompt(prompt)); + this.prompts.set( + entry.id, + this.registerDirectPrompt(entry, binding, completionCapletIds.has(entry.route.capletId)), + ); } } } @@ -270,7 +285,10 @@ export class CapletsMcpSession { this.prompts.clear(); } - private registerCodeModeTool(codeModeCaplets: CallableCaplet[]): RegisteredTool { + private registerCodeModeTool( + codeModeCaplets: ExposureProjectionCodeModeCaplet[], + binding: ExposureProjectionBinding, + ): RegisteredTool { return this.server.registerTool( "code_mode", { @@ -278,17 +296,27 @@ export class CapletsMcpSession { description: codeModeRunToolDescription(codeModeCaplets), inputSchema: codeModeRunParamsSchema, }, - async (request: unknown) => this.handleCodeModeRunTool(request), + async (request: unknown) => this.handleCodeModeRunTool(request, binding, codeModeCaplets), ); } - private async handleCodeModeRunTool(request: unknown): Promise { + private async handleCodeModeRunTool( + request: unknown, + binding: ExposureProjectionBinding, + codeModeCaplets: ExposureProjectionCodeModeCaplet[], + ): Promise { + this.assertProjectionBinding(binding); const started = Date.now(); const parsed = codeModeRunInputSchema.safeParse(request); const envelope = parsed.success ? await runCodeMode({ code: parsed.data.code, - service: new EngineNativeCapletsService(this.engine), + service: new EngineNativeCapletsService( + this.engine, + binding.generation, + codeModeCaplets, + () => this.isProjectionBindingCurrent(binding), + ), ...(parsed.data.timeoutMs === undefined ? {} : { timeoutMs: parsed.data.timeoutMs }), ...(parsed.data.sessionId === undefined ? {} : { sessionId: parsed.data.sessionId }), logStore: new CodeModeLogStore(), @@ -322,96 +350,181 @@ export class CapletsMcpSession { }; } - private registerCapletTool(caplet: CapletConfig): RegisteredTool { + private registerCapletTool( + entry: ExposureProjectionProgressiveCaplet, + binding: ExposureProjectionBinding, + ): RegisteredTool { + const inputSchema = zodSchemaForDirectTool(entry.inputSchema); return this.server.registerTool( - caplet.server, + entry.id, { - title: caplet.name, - description: capabilityDescription(caplet), - inputSchema: generatedToolInputSchemaForCaplet(caplet).shape, + ...(entry.title ? { title: entry.title } : {}), + ...(entry.description ? { description: entry.description } : {}), + ...(inputSchema ? { inputSchema: inputSchema.shape } : {}), + }, + async (request: unknown) => { + this.assertProjectionBinding(binding); + return this.engine.execute(entry.route.capletId, request) as never; }, - async (request: unknown) => this.engine.execute(caplet.server, request) as never, ); } - private registerDirectTool(entry: DirectToolRegistration): RegisteredTool { - const inputSchema = zodSchemaForDirectTool(entry.tool.inputSchema); - const outputSchema = zodSchemaForDirectTool(entry.tool.outputSchema); + private registerDirectTool( + entry: ExposureProjectionDirectTool, + binding: ExposureProjectionBinding, + ): RegisteredTool { + const inputSchema = zodSchemaForDirectTool(entry.inputSchema); + const outputSchema = zodSchemaForDirectTool(entry.outputSchema); return (this.server.registerTool as (...args: unknown[]) => RegisteredTool)( - entry.name, + entry.id, { - title: entry.tool.name, - ...(entry.tool.description ? { description: entry.tool.description } : {}), + title: entry.title, + ...(entry.description ? { description: entry.description } : {}), ...(inputSchema ? { inputSchema } : {}), ...(outputSchema ? { outputSchema } : {}), - ...(entry.tool.annotations ? { annotations: entry.tool.annotations } : {}), - _meta: { - caplets: { - capletId: entry.caplet.server, - downstreamName: entry.downstreamName, - exposure: "direct", - }, - }, + ...(entry.annotations ? { annotations: entry.annotations } : {}), + _meta: directToolMetadata(entry), }, - async (request: unknown) => - this.engine.executeDirectTool( - entry.caplet.server, - entry.downstreamName, + async (request: unknown) => { + this.assertProjectionBinding(binding); + return this.engine.executeDirectTool( + entry.route.capletId, + entry.route.downstreamName, isRecord(request) ? request : {}, - ) as never, + ) as never; + }, ); } - private registerDirectResource(entry: DirectResourceRegistration): RegisteredResource { + private registerDirectResource( + entry: ExposureProjectionDirectResource, + binding: ExposureProjectionBinding, + ): RegisteredResource { if (!this.server.registerResource) { throw new Error("MCP server does not support resource registration"); } return this.server.registerResource( - entry.resource.name ?? entry.uri, - entry.uri, - resourceMetadata(entry.resource), - async () => this.directResourceResult(entry.caplet.server, entry.downstreamUri), + entry.title ?? entry.id, + entry.id, + resourceMetadata(entry), + async () => + this.directResourceResult(binding, entry.route.capletId, entry.route.downstreamUri), ); } private registerDirectResourceTemplate( - entry: DirectResourceTemplateRegistration, + entry: ExposureProjectionDirectResourceTemplate, + binding: ExposureProjectionBinding, + completionEnabled: boolean, ): RegisteredResourceTemplate { if (!this.server.registerResource) { throw new Error("MCP server does not support resource registration"); } return this.server.registerResource( - `${entry.caplet.server}:${entry.resourceTemplate.name ?? entry.downstreamUriTemplate}`, - new ResourceTemplate(entry.uriTemplate, { list: undefined }), - resourceTemplateMetadata(entry.resourceTemplate), + `${entry.route.capletId}:${entry.title ?? entry.route.downstreamUriTemplate}`, + new ResourceTemplate(entry.id, { + list: undefined, + ...(completionEnabled ? { complete: this.resourceTemplateCompleters(entry, binding) } : {}), + }), + resourceTemplateMetadata(entry), async (uri) => { + this.assertProjectionBinding(binding); const decoded = decodeDirectResourceUri(uri.toString()); - return this.directResourceResult(decoded.capletId, decoded.downstreamUri); + return this.directResourceResult(binding, entry.route.capletId, decoded.downstreamUri); }, ); } - private registerDirectPrompt(entry: DirectPromptRegistration): RegisteredPrompt { + private registerDirectPrompt( + entry: ExposureProjectionDirectPrompt, + binding: ExposureProjectionBinding, + completionEnabled: boolean, + ): RegisteredPrompt { if (!this.server.registerPrompt) { throw new Error("MCP server does not support prompt registration"); } return this.server.registerPrompt( - entry.name, + entry.id, { - title: entry.prompt.name, - ...(entry.prompt.description ? { description: entry.prompt.description } : {}), - argsSchema: promptArgsSchema(entry.prompt.arguments), + ...(entry.title ? { title: entry.title } : {}), + ...(entry.description ? { description: entry.description } : {}), + argsSchema: promptArgsSchema( + entry.arguments, + completionEnabled + ? async (name, value, context) => { + this.assertProjectionBinding(binding); + return await this.engine.completeDirectReference( + entry.route.capletId, + { type: "prompt", name: entry.route.downstreamName }, + { name, value }, + context, + ); + } + : undefined, + ), }, - async (args) => - (await this.engine.getDirectPrompt( - entry.caplet.server, - entry.downstreamName, + async (args) => { + this.assertProjectionBinding(binding); + return (await this.engine.getDirectPrompt( + entry.route.capletId, + entry.route.downstreamName, isRecord(args) ? stringifyRecord(args) : {}, - )) as never, + )) as never; + }, + ); + } + + private resourceTemplateCompleters( + entry: ExposureProjectionDirectResourceTemplate, + binding: ExposureProjectionBinding, + ): Record< + string, + ( + value: string, + context?: { arguments?: Record | undefined }, + ) => Promise + > { + const exposedTemplate = new UriTemplate(entry.id); + const downstreamTemplate = new UriTemplate(entry.route.downstreamUriTemplate); + const downstreamVariables = new Set(downstreamTemplate.variableNames); + return Object.fromEntries( + exposedTemplate.variableNames.map((variable) => [ + variable, + async (value: string, context?: { arguments?: Record | undefined }) => { + this.assertProjectionBinding(binding); + const target = resourceTemplateCompletionTarget(entry.route.downstreamUriTemplate, value); + if (!target) return []; + const completionArguments = { + ...Object.fromEntries( + Object.entries(context?.arguments ?? {}).filter(([name]) => + downstreamVariables.has(name), + ), + ), + ...target.arguments, + }; + const completions = await this.engine.completeDirectReference( + entry.route.capletId, + { type: "resourceTemplate", uri: entry.route.downstreamUriTemplate }, + { name: target.name, value: target.value }, + { arguments: completionArguments }, + ); + return completions.map((completion) => + downstreamTemplate.expand({ + ...completionArguments, + [target.name]: completion, + }), + ); + }, + ]), ); } - private async directResourceResult(serverId: string, downstreamUri: string): Promise { + private async directResourceResult( + binding: ExposureProjectionBinding, + serverId: string, + downstreamUri: string, + ): Promise { + this.assertProjectionBinding(binding); const result = await this.engine.readDirectResource(serverId, downstreamUri); if (isRecord(result) && "contents" in result) return result; return { @@ -424,6 +537,22 @@ export class CapletsMcpSession { ], }; } + + private isProjectionBindingCurrent(binding: ExposureProjectionBinding): boolean { + return ( + this.projectionEpoch === binding.epoch && + this.resolvedProjection?.generation === binding.generation && + this.engine.currentExposureGeneration() === binding.generation + ); + } + + private assertProjectionBinding(binding: ExposureProjectionBinding): void { + if (this.isProjectionBindingCurrent(binding)) return; + throw new CapletsError( + "SERVER_UNAVAILABLE", + "Caplets exposure changed; wait for the current projection to resolve.", + ); + } } function zodSchemaForDirectTool(schema: unknown): z.ZodObject | undefined { @@ -459,84 +588,250 @@ function zodSchemaForDirectTool(schema: unknown): z.ZodObject | undefined { return converted; } -function codeModeRunToolDescription(caplets: CallableCaplet[]): string { +function codeModeRunToolDescription(caplets: ExposureProjectionCodeModeCaplet[]): string { const declaration = generateCodeModeDeclarations({ caplets: caplets.map((entry) => ({ - id: entry.caplet.server, - name: entry.caplet.name, - description: capabilityDescription(entry.caplet), - ...(entry.caplet.useWhen ? { useWhen: entry.caplet.useWhen } : {}), - ...(entry.caplet.avoidWhen ? { avoidWhen: entry.caplet.avoidWhen } : {}), + id: entry.id, + name: entry.title ?? entry.id, + description: entry.description ?? "", + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), })), }); return generateCodeModeRunToolDescription(declaration); } class EngineNativeCapletsService implements NativeCapletsService { - constructor(private readonly engine: CapletsEngine) {} + constructor( + private readonly engine: CapletsEngine, + private readonly generation: number, + private readonly caplets: ExposureProjectionCodeModeCaplet[], + private readonly isCurrentProjection: () => boolean, + ) {} listTools(): NativeCapletTool[] { - const snapshot = this.engine.currentExposureSnapshot(); - const caplets = - snapshot?.codeModeCaplets.map((entry) => entry.caplet) ?? this.engine.enabledServers(); - return caplets.map((caplet) => { - const toolName = nativeCapletToolName(caplet.server); - return { - caplet: caplet.server, - toolName, - title: caplet.name, - description: nativeCapletToolDescription(toolName, caplet), - promptGuidance: nativeCapletPromptGuidance(toolName, caplet), - }; - }); + if ( + this.engine.currentExposureGeneration() !== this.generation || + !this.isCurrentProjection() + ) { + return []; + } + return this.caplets.map((entry) => ({ + caplet: entry.id, + ...(entry.sourceCapletId ? { sourceCaplet: entry.sourceCapletId } : {}), + toolName: nativeCapletToolName(entry.id), + title: entry.title ?? entry.id, + description: entry.description ?? "", + ...(entry.shadowing ? { shadowing: entry.shadowing } : {}), + ...(entry.useWhen ? { useWhen: entry.useWhen } : {}), + ...(entry.avoidWhen ? { avoidWhen: entry.avoidWhen } : {}), + promptGuidance: [], + })); } async execute(capletId: string, request: unknown): Promise { + this.assertCurrent(); return await this.engine.execute(capletId, request); } async reload(): Promise { + this.assertCurrent(); return await this.engine.reload(); } onToolsChanged(listener: (tools: NativeCapletTool[]) => void): () => void { - return this.engine.onReload(() => listener(this.listTools())); + return this.engine.onReload(() => listener([])); } async close(): Promise { return; } + + private assertCurrent(): void { + if (this.engine.currentExposureGeneration() === this.generation && this.isCurrentProjection()) { + return; + } + throw new CapletsError( + "SERVER_UNAVAILABLE", + "Caplets exposure changed during Code Mode execution.", + ); + } +} + +function directToolMetadata(entry: ExposureProjectionDirectTool) { + return { + caplets: { + capletId: entry.route.capletId, + downstreamName: entry.route.downstreamName, + exposure: "direct", + }, + }; } -function resourceMetadata(resource: DirectResourceRegistration["resource"]) { +function resourceMetadata(resource: ExposureProjectionDirectResource) { return { ...(resource.description ? { description: resource.description } : {}), ...(resource.mimeType ? { mimeType: resource.mimeType } : {}), ...(typeof resource.size === "number" ? { size: resource.size } : {}), - _meta: { caplets: { downstreamUri: resource.uri, exposure: "direct" } }, + _meta: { + caplets: { downstreamUri: resource.route.downstreamUri, exposure: "direct" }, + }, }; } -function resourceTemplateMetadata( - resourceTemplate: DirectResourceTemplateRegistration["resourceTemplate"], -) { +function resourceTemplateMetadata(resourceTemplate: ExposureProjectionDirectResourceTemplate) { return { ...(resourceTemplate.description ? { description: resourceTemplate.description } : {}), ...(resourceTemplate.mimeType ? { mimeType: resourceTemplate.mimeType } : {}), _meta: { - caplets: { downstreamUriTemplate: resourceTemplate.uriTemplate, exposure: "direct" }, + caplets: { + downstreamUriTemplate: resourceTemplate.route.downstreamUriTemplate, + exposure: "direct", + }, }, }; } -function promptArgsSchema(args: DirectPromptRegistration["prompt"]["arguments"]) { +function promptArgsSchema( + args: ExposureProjectionPromptArgument[], + complete?: + | (( + name: string, + value: string, + context?: { arguments?: Record | undefined }, + ) => Promise) + | undefined, +) { const shape: Record = {}; - for (const arg of args ?? []) { - shape[arg.name] = z.string().optional(); + for (const arg of args) { + const described = arg.description ? z.string().describe(arg.description) : z.string(); + const schema = arg.required ? described : described.optional(); + shape[arg.name] = complete + ? completable( + schema, + async (value, context) => await complete(arg.name, String(value ?? ""), context), + ) + : schema; } return shape; } +function resourceTemplateCompletionTarget( + template: string, + partialUri: string, +): + | { + name: string; + value: string; + arguments: Record; + } + | undefined { + const slots = resourceTemplateSlots(template); + if (slots.length === 0) return undefined; + const namedTarget = namedResourceTemplateCompletionTarget(template, partialUri); + if (namedTarget) return namedTarget; + const arguments_: Record = {}; + let offset = 0; + for (const [index, slot] of slots.entries()) { + const rawFirstVariable = index === 0 && !partialUri.startsWith(slot.prefix); + if (!rawFirstVariable) { + if (!partialUri.startsWith(slot.prefix, offset)) return undefined; + offset += slot.prefix.length; + } + const nextPrefix = slots[index + 1]?.prefix; + const nextOffset = nextPrefix ? partialUri.indexOf(nextPrefix, offset) : -1; + if (!nextPrefix || nextOffset === -1) { + return { + name: slot.name, + value: decodeCompletionComponent(partialUri.slice(offset)), + arguments: arguments_, + }; + } + arguments_[slot.name] = decodeCompletionComponent(partialUri.slice(offset, nextOffset)); + offset = nextOffset; + } + return undefined; +} + +function namedResourceTemplateCompletionTarget( + template: string, + partialUri: string, +): { name: string; value: string; arguments: Record } | undefined { + const queryIndex = partialUri.indexOf("?"); + if (queryIndex === -1) return undefined; + const namedVariables = new Set( + [...template.matchAll(/\{[?&;]([^}]+)\}/gu)].flatMap((match) => + (match[1] ?? "") + .split(",") + .map((name) => name.replace(/[:*].*$/u, "").trim()) + .filter(Boolean), + ), + ); + const query = partialUri.slice(queryIndex + 1); + const current = query.split(/[&;]/u).at(-1) ?? ""; + const separator = current.indexOf("="); + if (separator === -1) return undefined; + const name = decodeCompletionComponent(current.slice(0, separator)); + if (!namedVariables.has(name)) return undefined; + const value = decodeCompletionComponent(current.slice(separator + 1)); + const arguments_: Record = {}; + for (const [argumentName, argumentValue] of new URLSearchParams(query)) { + if (argumentName !== name && namedVariables.has(argumentName)) { + arguments_[argumentName] = argumentValue; + } + } + const positionalTemplate = template.replace(/\{[?&;][^}]+\}/gu, ""); + const matched = new UriTemplate(positionalTemplate).match(partialUri.slice(0, queryIndex)); + for (const [argumentName, argumentValue] of Object.entries(matched ?? {})) { + if (argumentName !== name && typeof argumentValue === "string") { + arguments_[argumentName] = decodeCompletionComponent(argumentValue); + } + } + return { name, value, arguments: arguments_ }; +} + +function resourceTemplateSlots(template: string): Array<{ name: string; prefix: string }> { + const slots: Array<{ name: string; prefix: string }> = []; + let pending = ""; + let offset = 0; + for (const match of template.matchAll(/\{([^}]+)\}/gu)) { + pending += template.slice(offset, match.index); + const expression = match[1] ?? ""; + const operator = expression.match(/^[+#./;?&]/u)?.[0] ?? ""; + const names = (operator ? expression.slice(1) : expression) + .split(",") + .map((name) => name.replace(/[:*].*$/u, "").trim()) + .filter(Boolean); + for (const [index, name] of names.entries()) { + slots.push({ + name, + prefix: pending + resourceTemplateVariablePrefix(operator, name, index), + }); + pending = ""; + } + offset = (match.index ?? 0) + match[0].length; + } + return slots; +} + +function resourceTemplateVariablePrefix(operator: string, name: string, index: number): string { + if (operator === "/") return "/"; + if (operator === ".") return "."; + if (operator === ";") return `;${name}=`; + if (operator === "?") return `${index === 0 ? "?" : "&"}${name}=`; + if (operator === "&") return `&${name}=`; + if (operator === "#") return index === 0 ? "#" : ","; + return index === 0 ? "" : ","; +} + +function decodeCompletionComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + function stringifyRecord(value: Record): Record { return Object.fromEntries( Object.entries(value).map(([key, nested]) => [key, nested === undefined ? "" : String(nested)]), @@ -546,53 +841,3 @@ function stringifyRecord(value: Record): Record function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } - -function staticExposureSnapshot( - config: CapletsConfig, - caplets: CapletConfig[], - projectBindingContext?: ProjectBindingExecutionContext | undefined, -): ExposureSnapshot { - const callableCaplets = caplets - .filter( - (caplet) => - !caplet.disabled && - !caplet.setup && - (!caplet.projectBinding?.required || projectBindingContext), - ) - .map((caplet) => ({ - caplet, - exposure: resolveExposure(caplet.exposure, config.options.exposure), - tools: [], - resources: [], - resourceTemplates: [], - prompts: [], - discoveredAt: Date.now(), - })); - return { - callableCaplets, - progressiveCaplets: callableCaplets.filter((entry) => entry.exposure.progressive), - codeModeCaplets: callableCaplets.filter((entry) => entry.exposure.codeMode), - directTools: [], - directResources: [], - directResourceTemplates: [], - directPrompts: [], - hiddenCaplets: caplets - .filter( - (caplet) => - caplet.disabled || - caplet.setup || - (caplet.projectBinding?.required && !projectBindingContext), - ) - .map((caplet) => ({ - capletId: caplet.server, - reason: caplet.disabled - ? ("disabled" as const) - : caplet.setup - ? ("setup_required" as const) - : ("project_binding_missing_context" as const), - ...(caplet.projectBinding?.required && !projectBindingContext - ? { error: projectBindingMissingContextDiagnostic() } - : {}), - })), - }; -} diff --git a/packages/core/src/tools.ts b/packages/core/src/tools.ts index cef65f16..e46dc7cf 100644 --- a/packages/core/src/tools.ts +++ b/packages/core/src/tools.ts @@ -358,6 +358,7 @@ export async function handleServerTool( const result = await mcpBackendFor(server, runtime.mcp, "direct")!.complete(server as never, { ref: parsed.ref, argument: parsed.argument, + ...(parsed.context ? { context: parsed.context } : {}), }); return annotateMcpResult(result, metadataFor(server, "complete", undefined, startedAt)); } @@ -873,10 +874,15 @@ export function validateOperationRequest( } return { operation: "get_prompt", name: value.name, args: value.args ?? {} }; case "complete": - allowed(["ref", "argument"]); + allowed(["ref", "argument", "context"]); if (!value.ref) throw new CapletsError("REQUEST_INVALID", "complete requires ref"); if (!value.argument) throw new CapletsError("REQUEST_INVALID", "complete requires argument"); - return { operation: "complete", ref: value.ref, argument: value.argument }; + return { + operation: "complete", + ref: value.ref, + argument: value.argument, + ...(value.context ? { context: value.context } : {}), + }; } throw new CapletsError("INTERNAL_ERROR", "Unhandled operation"); } @@ -939,6 +945,7 @@ type RequiredOperationRequest = operation: "complete"; ref: { type: "prompt"; name: string } | { type: "resourceTemplate"; uri: string }; argument: { name: string; value: string }; + context?: { arguments?: Record | undefined } | undefined; }; export type CapletArtifact = diff --git a/packages/core/test/attach-api.test.ts b/packages/core/test/attach-api.test.ts index e9276d5d..0cec9b01 100644 --- a/packages/core/test/attach-api.test.ts +++ b/packages/core/test/attach-api.test.ts @@ -14,46 +14,44 @@ import { parseConfig } from "../src/config"; import { CapletsEngine } from "../src/engine"; import type { NativeCapletsService } from "../src/native/service"; import { sanitizeRemoteEngineOptions } from "../src/serve/http"; +import { + buildExposureProjection, + buildManifestExposureProjection, + type ExposureProjection, +} from "../src/exposure/projection"; describe("Attach API dispatch", () => { it("sorts attach exports before hashing revisions", async () => { - const caplet = { - server: "docs", - name: "Docs", - description: "Docs.", - backend: "mcp", - command: process.execPath, - }; let reversed = false; const tools = [ { - caplet, + kind: "tool" as const, + capletId: "docs", downstreamName: "beta", name: "docs__beta", - tool: { name: "beta", inputSchema: { type: "object" } }, + inputSchema: { type: "object" }, + shadowing: "forbid" as const, }, { - caplet, + kind: "tool" as const, + capletId: "docs", downstreamName: "alpha", name: "docs__alpha", - tool: { name: "alpha", inputSchema: { type: "object" } }, + inputSchema: { type: "object" }, + shadowing: "forbid" as const, }, ]; - const engine = { - exposureSnapshot: async () => { - reversed = !reversed; - return { - callableCaplets: [], - progressiveCaplets: [], - codeModeCaplets: [], - directTools: reversed ? tools : [...tools].reverse(), - directResources: [], - directResourceTemplates: [], - directPrompts: [], - hiddenCaplets: [], - }; - }, - } as unknown as CapletsEngine; + const engine = projectionEngine(() => { + reversed = !reversed; + return buildManifestExposureProjection({ + caplets: [], + tools: reversed ? tools : [...tools].reverse(), + resources: [], + resourceTemplates: [], + prompts: [], + completions: [], + }); + }); const first = await buildAttachProjection(engine); const second = await buildAttachProjection(engine); @@ -66,36 +64,26 @@ describe("Attach API dispatch", () => { }); it("preserves direct tool annotations in attach manifests", async () => { - const caplet = { - server: "docs", - name: "Docs", - description: "Docs.", - backend: "mcp", - command: process.execPath, - }; - const engine = { - exposureSnapshot: async () => ({ - callableCaplets: [], - progressiveCaplets: [], - codeModeCaplets: [], - directTools: [ + const engine = projectionEngine(() => + buildManifestExposureProjection({ + caplets: [], + tools: [ { - caplet, + kind: "tool", + capletId: "docs", downstreamName: "delete", name: "docs__delete", - tool: { - name: "delete", - inputSchema: { type: "object" }, - annotations: { destructiveHint: true }, - }, + inputSchema: { type: "object" }, + annotations: { destructiveHint: true }, + shadowing: "forbid", }, ], - directResources: [], - directResourceTemplates: [], - directPrompts: [], - hiddenCaplets: [], + resources: [], + resourceTemplates: [], + prompts: [], + completions: [], }), - } as unknown as CapletsEngine; + ); const projection = await buildAttachProjection(engine); @@ -107,15 +95,9 @@ describe("Attach API dispatch", () => { }); it("sanitizes hidden discovery diagnostics through exposure projection", async () => { - const engine = { - exposureSnapshot: async () => ({ + const engine = projectionEngine(() => + buildExposureProjection({ callableCaplets: [], - progressiveCaplets: [], - codeModeCaplets: [], - directTools: [], - directResources: [], - directResourceTemplates: [], - directPrompts: [], hiddenCaplets: [ { capletId: "vaulted", @@ -131,7 +113,7 @@ describe("Attach API dispatch", () => { }, ], }), - } as unknown as CapletsEngine; + ); const projection = await buildAttachProjection(engine); @@ -149,15 +131,9 @@ describe("Attach API dispatch", () => { }); it("includes authoritative Project Binding metadata for hidden Caplets", async () => { - const engine = { - exposureSnapshot: async () => ({ + const engine = projectionEngine(() => + buildExposureProjection({ callableCaplets: [], - progressiveCaplets: [], - codeModeCaplets: [], - directTools: [], - directResources: [], - directResourceTemplates: [], - directPrompts: [], hiddenCaplets: [ { capletId: "workspace", @@ -177,7 +153,7 @@ describe("Attach API dispatch", () => { }, ], }), - } as unknown as CapletsEngine; + ); const projection = await buildAttachProjection(engine); @@ -198,33 +174,40 @@ describe("Attach API dispatch", () => { }); it("uses configured Caplet shadowing policy in attach manifests", async () => { - const caplet = { - server: "docs", - name: "Docs", - description: "Docs.", - backend: "mcp", - command: process.execPath, - shadowing: "namespace", - }; - const engine = { - exposureSnapshot: async () => ({ - callableCaplets: [], - progressiveCaplets: [{ caplet }], - codeModeCaplets: [{ caplet }], - directTools: [ + const engine = projectionEngine(() => + buildManifestExposureProjection({ + caplets: [ { - caplet, + kind: "caplet", + name: "Docs", + capletId: "docs", + shadowing: "namespace", + }, + ], + tools: [ + { + kind: "tool", + capletId: "docs", downstreamName: "read", name: "docs__read", - tool: { name: "read", inputSchema: { type: "object" } }, + inputSchema: { type: "object" }, + shadowing: "namespace", + }, + ], + resources: [], + resourceTemplates: [], + prompts: [], + completions: [], + codeModeCaplets: [ + { + kind: "caplet", + name: "Docs", + capletId: "docs", + shadowing: "namespace", }, ], - directResources: [], - directResourceTemplates: [], - directPrompts: [], - hiddenCaplets: [], }), - } as unknown as CapletsEngine; + ); const projection = await buildAttachProjection(engine); @@ -364,6 +347,8 @@ describe("Attach API dispatch", () => { id: "filesystem", name: "Filesystem", description: "Filesystem.", + useWhen: "Use for repository files.", + avoidWhen: "Avoid for network calls.", shadowing: "allow", }, ], @@ -384,57 +369,48 @@ describe("Attach API dispatch", () => { kind: "caplet", name: "Filesystem", capletId: "filesystem", + useWhen: "Use for repository files.", + avoidWhen: "Avoid for network calls.", shadowing: "allow", }), ]); }); it("preserves direct resource metadata in attach manifests", async () => { - const caplet = { - server: "docs", - name: "Docs", - description: "Docs.", - backend: "mcp", - command: process.execPath, - }; - const engine = { - exposureSnapshot: async () => ({ - callableCaplets: [], - progressiveCaplets: [], - codeModeCaplets: [], - directTools: [], - directResources: [ + const engine = projectionEngine(() => + buildManifestExposureProjection({ + caplets: [], + tools: [], + resources: [ { - caplet, - downstreamUri: "file:///README.md", + kind: "resource", + capletId: "docs", uri: "caplets://docs/resources/file%3A%2F%2F%2FREADME.md", - resource: { - uri: "file:///README.md", - name: "README", - description: "README resource.", - mimeType: "text/markdown", - size: 42, - }, + downstreamUri: "file:///README.md", + title: "README", + description: "README resource.", + mimeType: "text/markdown", + size: 42, + shadowing: "forbid", }, ], - directResourceTemplates: [ + resourceTemplates: [ { - caplet, - downstreamUriTemplate: "file:///{path}", + kind: "resourceTemplate", + capletId: "docs", uriTemplate: "caplets://docs/resources/{encodedUri}?template=file%3A%2F%2F%2F%7Bpath%7D", - resourceTemplate: { - uriTemplate: "file:///{path}", - name: "File", - description: "File resource.", - mimeType: "text/plain", - }, + downstreamUriTemplate: "file:///{path}", + title: "File", + description: "File resource.", + mimeType: "text/plain", + shadowing: "forbid", }, ], - directPrompts: [], - hiddenCaplets: [], + prompts: [], + completions: [], }), - } as unknown as CapletsEngine; + ); const projection = await buildAttachProjection(engine); @@ -791,6 +767,7 @@ describe("Attach API dispatch", () => { input: { ref: { type: "prompt", name: "docs__review" }, argument: { name: "topic", value: "attach" }, + context: { arguments: { owner: "caplets" } }, }, }); @@ -798,6 +775,7 @@ describe("Attach API dispatch", () => { operation: "complete", ref: { type: "prompt", name: "review" }, argument: { name: "topic", value: "attach" }, + context: { arguments: { owner: "caplets" } }, }); }); @@ -928,8 +906,43 @@ describe("Attach API dispatch", () => { argument: { name: "topic", value: "attach" }, }); }); + it("rejects a projection that becomes stale before Attach publishes it", async () => { + const pending = Promise.withResolvers<{ + generation: number; + projection: ExposureProjection; + }>(); + let generation = 0; + const engine = { + exposureProjection: async () => await pending.promise, + currentExposureGeneration: () => generation, + } as unknown as CapletsEngine; + + const building = buildAttachProjection(engine); + generation = 1; + pending.resolve({ + generation: 0, + projection: buildManifestExposureProjection({ + caplets: [], + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + completions: [], + codeModeCaplets: [], + }), + }); + + await expect(building).rejects.toMatchObject({ code: "SERVER_UNAVAILABLE" }); + }); }); +function projectionEngine(factory: () => ExposureProjection): CapletsEngine { + return { + exposureProjection: async () => ({ generation: 0, projection: factory() }), + currentExposureGeneration: () => 0, + } as unknown as CapletsEngine; +} + async function startPdfServer(): Promise<{ baseUrl: string; close: () => Promise }> { const server = createServer((_request, response) => { response.setHeader("content-type", "application/pdf"); diff --git a/packages/core/test/backend-operation-dispatch.test.ts b/packages/core/test/backend-operation-dispatch.test.ts index 4b7a7fb8..aec91bb7 100644 --- a/packages/core/test/backend-operation-dispatch.test.ts +++ b/packages/core/test/backend-operation-dispatch.test.ts @@ -179,6 +179,20 @@ describe("backend operation dispatch", () => { registry, runtime, ); + const contextualCompletion = await handleServerTool( + server, + { + operation: "complete", + ref: { + type: "resourceTemplate", + uri: "repo://{owner}/{name}{?region}", + }, + argument: { name: "name", value: "co" }, + context: { arguments: { owner: "caplets", region: "eu" } }, + } as Parameters[1], + registry, + runtime, + ); expect(resources.structuredContent).toMatchObject({ result: { @@ -188,7 +202,11 @@ describe("backend operation dispatch", () => { }, }); expect(templates.structuredContent).toMatchObject({ - result: { items: [{ uriTemplate: "file:///repo/{path}" }] }, + result: { + items: expect.arrayContaining([ + expect.objectContaining({ uriTemplate: "file:///repo/{path}" }), + ]), + }, }); expect(resource.contents).toEqual([ { @@ -204,6 +222,7 @@ describe("backend operation dispatch", () => { { role: "user", content: { type: "text", text: "Review CAP-123" } }, ]); expect(completion.completion.values).toEqual(["README.md"]); + expect(contextualCompletion).toMatchObject({ completion: { values: ["core"] } }); } finally { await mcp.close(); } diff --git a/packages/core/test/code-mode-cli.test.ts b/packages/core/test/code-mode-cli.test.ts index 76818011..4a9344bc 100644 --- a/packages/core/test/code-mode-cli.test.ts +++ b/packages/core/test/code-mode-cli.test.ts @@ -119,6 +119,33 @@ describe("Code Mode CLI", () => { rmSync(dir, { recursive: true, force: true }); } }); + it("waits for the initial resolved projection before running one-shot Code Mode", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-cli-")); + const out: string[] = []; + try { + process.env.CAPLETS_CONFIG = writeConfig(dir, { + mcpServers: { + github: { + name: "GitHub", + description: "GitHub repo operations.", + command: "node", + exposure: "code_mode", + }, + }, + }); + + await runCli(["code-mode", "return Object.keys(caplets).sort();", "--json"], { + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toMatchObject({ + ok: true, + value: ["debug", "github"], + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); it("reads --file paths relative to the current working directory", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-cli-")); @@ -217,6 +244,42 @@ describe("Code Mode CLI", () => { rmSync(dir, { recursive: true, force: true }); } }); + it("derives generated callable counts from resolved Code Mode entries", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-cli-")); + const out: string[] = []; + try { + process.env.CAPLETS_CONFIG = writeConfig(dir, { + options: { exposureDiscoveryTimeoutMs: 100 }, + mcpServers: { + github: { + name: "GitHub", + description: "GitHub repo operations.", + command: "node", + exposure: "code_mode", + }, + }, + openapiEndpoints: { + unavailable: { + name: "Unavailable API", + description: "An unavailable OpenAPI service.", + specUrl: "http://127.0.0.1:1/openapi.json", + auth: { type: "none" }, + exposure: "code_mode", + }, + }, + }); + + await runCli(["code-mode", "types", "--json"], { + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toMatchObject({ + callableCount: 1, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); it("prints unsupported repl scaffolding as a JSON envelope", async () => { const out: string[] = []; diff --git a/packages/core/test/code-mode-mcp.test.ts b/packages/core/test/code-mode-mcp.test.ts index 7151e7cc..dbaca415 100644 --- a/packages/core/test/code-mode-mcp.test.ts +++ b/packages/core/test/code-mode-mcp.test.ts @@ -25,6 +25,7 @@ describe("Code Mode MCP tool", () => { const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); const server = mockServer(); const session = new CapletsMcpSession(engine, { server }); + await session.refreshExposure(); expect(session.registeredToolIds()).toEqual(["github"]); expect(server.registered.get("github")).toBeDefined(); @@ -46,6 +47,50 @@ describe("Code Mode MCP tool", () => { await engine.close(); }); + it("exposes only ready Code Mode Caplets and rejects hidden handle references", async () => { + const ready = { + name: "Ready", + description: "Ready HTTP Caplet.", + exposure: "code_mode", + baseUrl: "http://127.0.0.1:1", + auth: { type: "none" }, + actions: { ping: { method: "GET", path: "/ping" } }, + }; + const { dir, configPath, projectConfigPath } = tempConfig({ + httpApis: { + ready, + disabled: { ...ready, name: "Disabled", disabled: true }, + setup: { + ...ready, + name: "Setup", + setup: { commands: [{ label: "Install", command: "install-setup" }] }, + }, + }, + }); + dirs.push(dir); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + const server = mockServer(); + const session = new CapletsMcpSession(engine, { server }); + await session.refreshExposure(); + const callback = server.callbacks.get("code_mode"); + const execute = vi.spyOn(engine, "execute"); + + const listed = await callback?.({ code: "return Object.keys(caplets);" }); + const disabled = await callback?.({ code: "return caplets.disabled.inspect();" }); + const setup = await callback?.({ code: "return caplets.setup.inspect();" }); + + expect(listed?.structuredContent).toMatchObject({ + ok: true, + value: ["ready", "debug"], + }); + expect(disabled?.structuredContent).toMatchObject({ ok: false }); + expect(setup?.structuredContent).toMatchObject({ ok: false }); + expect(execute).not.toHaveBeenCalled(); + + await session.close(); + await engine.close(); + }); + it("returns a structured run envelope from the code_mode tool", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ mcpServers: { @@ -56,6 +101,7 @@ describe("Code Mode MCP tool", () => { const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); const server = mockServer(); const session = new CapletsMcpSession(engine, { server }); + await session.refreshExposure(); const callback = server.callbacks.get("code_mode"); const result = await callback?.({ code: "return { ok: true };" }); @@ -85,6 +131,7 @@ describe("Code Mode MCP tool", () => { const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); const server = mockServer(); const session = new CapletsMcpSession(engine, { server }); + await session.refreshExposure(); const callback = server.callbacks.get("code_mode"); const first = await callback?.({ code: "var counter = 1;\nreturn counter;" }); @@ -124,6 +171,7 @@ describe("Code Mode MCP tool", () => { const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); const server = mockServer(); const session = new CapletsMcpSession(engine, { server }); + await session.refreshExposure(); const callback = server.callbacks.get("code_mode"); const result = await callback?.({ timeoutMs: 1000 }); diff --git a/packages/core/test/exposure-discovery.test.ts b/packages/core/test/exposure-discovery.test.ts index 5a5dcf78..7f65df3d 100644 --- a/packages/core/test/exposure-discovery.test.ts +++ b/packages/core/test/exposure-discovery.test.ts @@ -2,6 +2,7 @@ import type { ResourceTemplate, Tool } from "@modelcontextprotocol/sdk/types"; import { describe, expect, it, vi } from "vitest"; import type { CapletConfig, CapletsConfig } from "../src/config"; import { discoverExposureSnapshot } from "../src/exposure/discovery"; +import { buildExposureProjection } from "../src/exposure/projection"; describe("exposure discovery", () => { it("discovers callable direct and Code Mode surfaces", async () => { @@ -11,11 +12,40 @@ describe("exposure discovery", () => { caplets: [caplet], listTools: async () => [tool("query")], }); + const projection = buildExposureProjection(snapshot); expect(snapshot.callableCaplets.map((entry) => entry.caplet.server)).toEqual(["osv"]); - expect(snapshot.codeModeCaplets.map((entry) => entry.caplet.server)).toEqual(["osv"]); - expect(snapshot.directTools.map((entry) => entry.name)).toEqual(["osv__query"]); - expect(snapshot.progressiveCaplets).toEqual([]); + expect( + projection.entries + .filter((entry) => entry.kind === "code-mode-caplet") + .map((entry) => entry.capletId), + ).toEqual(["osv"]); + expect( + projection.entries.filter((entry) => entry.kind === "direct-tool").map((entry) => entry.id), + ).toEqual(["osv__query"]); + expect(projection.entries.filter((entry) => entry.kind === "progressive-caplet")).toEqual([]); + }); + + it("keeps disabled and setup-required Code Mode Caplets out of callable projection", async () => { + const disabled = { ...httpCaplet("disabled", "code_mode"), disabled: true }; + const setupRequired = { + ...httpCaplet("setup", "code_mode"), + setup: { commands: [{ label: "Install", command: "install-setup" }] }, + }; + const listTools = vi.fn(async () => [tool("run")]); + const snapshot = await discoverExposureSnapshot({ + config: configFor([disabled, setupRequired]), + caplets: [disabled, setupRequired], + listTools, + }); + const projection = buildExposureProjection(snapshot); + + expect(projection.entries).toEqual([]); + expect(projection.hiddenCaplets).toEqual([ + expect.objectContaining({ capletId: "disabled", reason: "disabled" }), + expect.objectContaining({ capletId: "setup", reason: "setup_required" }), + ]); + expect(listTools).not.toHaveBeenCalled(); }); it("hides failed discovery without failing the whole snapshot", async () => { @@ -29,11 +59,14 @@ describe("exposure discovery", () => { return [tool("search")]; }, }); + const projection = buildExposureProjection(snapshot); - expect(snapshot.directTools).toEqual([]); - expect(snapshot.progressiveCaplets.map((entry) => entry.caplet.server)).toEqual([ - "progressive", - ]); + expect(projection.entries.filter((entry) => entry.kind === "direct-tool")).toEqual([]); + expect( + projection.entries + .filter((entry) => entry.kind === "progressive-caplet") + .map((entry) => entry.capletId), + ).toEqual(["progressive"]); expect(snapshot.hiddenCaplets).toEqual([ expect.objectContaining({ capletId: "direct", reason: "discovery_failed" }), ]); @@ -163,8 +196,13 @@ describe("exposure discovery", () => { resourceTemplate("git://repo/{ref}/{path}"), ], }); + const projection = buildExposureProjection(snapshot); - expect(snapshot.directResourceTemplates.map((entry) => entry.uriTemplate)).toEqual([ + expect( + projection.entries + .filter((entry) => entry.kind === "direct-resource-template") + .map((entry) => entry.id), + ).toEqual([ "caplets://docs/resources/{encodedUri}?template=file%3A%2F%2F%2Frepo%2F%7Bpath%7D", "caplets://docs/resources/{encodedUri}?template=git%3A%2F%2Frepo%2F%7Bref%7D%2F%7Bpath%7D", ]); @@ -189,8 +227,9 @@ describe("exposure discovery", () => { return [tool("run")]; }), }); + const projection = buildExposureProjection(snapshot); - expect(snapshot.directTools).toHaveLength(3); + expect(projection.entries.filter((entry) => entry.kind === "direct-tool")).toHaveLength(3); expect(maxActive).toBeLessThanOrEqual(2); }); }); diff --git a/packages/core/test/exposure-projection.test.ts b/packages/core/test/exposure-projection.test.ts index 92da2963..a0dd3cb6 100644 --- a/packages/core/test/exposure-projection.test.ts +++ b/packages/core/test/exposure-projection.test.ts @@ -27,43 +27,19 @@ describe("Caplets exposure projection", () => { tools: [tool("read")], resources: [resource("file:///README.md")], resourceTemplates: [resourceTemplate("file:///{path}")], - prompts: [prompt("summarize")], + prompts: [ + { + ...prompt("summarize"), + arguments: [{ name: "topic", description: "Topic to summarize.", required: true }], + }, + ], + completions: true, exposure: { value: "direct", progressive: false, direct: true, codeMode: false }, }); const projection = buildExposureProjection( snapshot({ callableCaplets: [http, docs], - progressiveCaplets: [http], - codeModeCaplets: [http], - directTools: [ - { caplet: docsCaplet, downstreamName: "read", name: "docs__read", tool: tool("read") }, - ], - directResources: [ - { - caplet: docsCaplet, - downstreamUri: "file:///README.md", - uri: "caplets://docs/resources/file%3A%2F%2F%2FREADME.md", - resource: resource("file:///README.md"), - }, - ], - directResourceTemplates: [ - { - caplet: docsCaplet, - downstreamUriTemplate: "file:///{path}", - uriTemplate: - "caplets://docs/resources/{encodedUri}?template=file%3A%2F%2F%2F%7Bpath%7D", - resourceTemplate: resourceTemplate("file:///{path}"), - }, - ], - directPrompts: [ - { - caplet: docsCaplet, - downstreamName: "summarize", - name: "docs__summarize", - prompt: prompt("summarize"), - }, - ], }), ); @@ -97,6 +73,101 @@ describe("Caplets exposure projection", () => { expect(projection.routes.get("direct-tool:docs__read")).not.toEqual( expect.objectContaining({ callback: expect.any(Function) }), ); + expect(projection.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "progressive-caplet", + id: "search", + title: "search", + description: expect.stringContaining("Call search actions."), + backend: "http", + inputSchema: expect.objectContaining({ type: "object" }), + operationNames: [ + "inspect", + "check", + "tools", + "search_tools", + "describe_tool", + "call_tool", + ], + route: { kind: "progressive-caplet", capletId: "search" }, + }), + expect.objectContaining({ + kind: "code-mode-caplet", + id: "search", + title: "search", + description: expect.stringContaining("Call search actions."), + backend: "http", + route: { kind: "code-mode-caplet", capletId: "search" }, + }), + expect.objectContaining({ + kind: "direct-tool", + id: "docs__read", + title: "read", + description: "Run read.", + inputSchema: { type: "object" }, + route: { kind: "direct-tool", capletId: "docs", downstreamName: "read" }, + }), + expect.objectContaining({ + kind: "direct-resource", + id: "caplets://docs/resources/file%3A%2F%2F%2FREADME.md", + title: "file:///README.md", + route: { + kind: "direct-resource", + capletId: "docs", + downstreamUri: "file:///README.md", + }, + }), + expect.objectContaining({ + kind: "direct-resource-template", + title: "file:///{path}", + route: { + kind: "direct-resource-template", + capletId: "docs", + downstreamUriTemplate: "file:///{path}", + }, + }), + expect.objectContaining({ + kind: "direct-prompt", + id: "docs__summarize", + inputSchema: { + arguments: [{ name: "topic", description: "Topic to summarize.", required: true }], + }, + arguments: [{ name: "topic", description: "Topic to summarize.", required: true }], + route: { kind: "direct-prompt", capletId: "docs", downstreamName: "summarize" }, + }), + expect.objectContaining({ + kind: "completion", + id: "docs:complete", + route: { kind: "completion", capletId: "docs" }, + }), + ]), + ); + }); + + it("projects completion only when the downstream capability was discovered", () => { + const caplet = mcpCaplet("docs", "direct"); + const withoutCompletions = callable(caplet, { + exposure: { value: "direct", progressive: false, direct: true, codeMode: false }, + resourceTemplates: [resourceTemplate("file:///{path}")], + completions: false, + }); + const withCompletions = callable(caplet, { + exposure: { value: "direct", progressive: false, direct: true, codeMode: false }, + resourceTemplates: [resourceTemplate("file:///{path}")], + completions: true, + }); + + expect( + buildExposureProjection(snapshot({ callableCaplets: [withoutCompletions] })).entries.some( + (entry) => entry.kind === "completion", + ), + ).toBe(false); + expect( + buildExposureProjection(snapshot({ callableCaplets: [withCompletions] })).entries.some( + (entry) => entry.kind === "completion", + ), + ).toBe(true); }); it("keeps hidden Caplets non-callable while exposing safe diagnostic breadcrumbs", () => { @@ -154,8 +225,6 @@ describe("Caplets exposure projection", () => { const projection = buildExposureProjection( snapshot({ callableCaplets: [docs], - progressiveCaplets: [docs], - codeModeCaplets: [docs], }), ); @@ -536,14 +605,9 @@ function renameMergeTool(tool: MergeTool, visibleBaseId: string): MergeTool { } function snapshot(overrides: Partial): ExposureSnapshot { + const callableCaplets = overrides.callableCaplets ?? []; return { - callableCaplets: [], - progressiveCaplets: [], - codeModeCaplets: [], - directTools: [], - directResources: [], - directResourceTemplates: [], - directPrompts: [], + callableCaplets, hiddenCaplets: [], ...overrides, }; @@ -557,6 +621,7 @@ function callable( resources?: Resource[] | undefined; resourceTemplates?: ResourceTemplate[] | undefined; prompts?: Prompt[] | undefined; + completions?: boolean | undefined; }, ): CallableCaplet { return { @@ -566,6 +631,7 @@ function callable( resources: options.resources ?? [], resourceTemplates: options.resourceTemplates ?? [], prompts: options.prompts ?? [], + completions: options.completions ?? false, discoveredAt, }; } diff --git a/packages/core/test/fixtures/stdio-server.ts b/packages/core/test/fixtures/stdio-server.ts index 221cc8ae..28fb43d0 100644 --- a/packages/core/test/fixtures/stdio-server.ts +++ b/packages/core/test/fixtures/stdio-server.ts @@ -1,7 +1,9 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp"; +import { completable } from "@modelcontextprotocol/sdk/server/completable"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio"; import { z } from "zod"; +const completionsEnabled = !process.argv.includes("--no-completions"); const server = new McpServer({ name: "fixture-stdio", version: "1.0.0" }); server.registerTool( @@ -43,9 +45,14 @@ server.registerResource( { uri: "file:///repo/src/index.ts", name: "src/index.ts", mimeType: "text/typescript" }, ], }), - complete: { - path: (value) => ["README.md", "src/index.ts"].filter((path) => path.startsWith(value)), - }, + ...(completionsEnabled + ? { + complete: { + path: (value: string) => + ["README.md", "src/index.ts"].filter((path) => path.startsWith(value)), + }, + } + : {}), }), { description: "Read a repository file", mimeType: "text/plain" }, async (uri) => ({ @@ -53,11 +60,71 @@ server.registerResource( }), ); +server.registerResource( + "Repository package", + new ResourceTemplate("repo://{owner}/{name}{?region}", { + list: undefined, + ...(completionsEnabled + ? { + complete: { + owner: (value: string) => + ["caplets", "other"].filter((owner) => owner.startsWith(value)), + name: (value: string, context?: { arguments?: Record }) => + (["caplets", "caplets inc"].includes(context?.arguments?.owner ?? "") && + context?.arguments?.region === "eu" + ? ["core", "cli"] + : ["unknown"] + ).filter((name) => name.startsWith(value)), + region: (value: string) => ["eu", "us"].filter((region) => region.startsWith(value)), + }, + } + : {}), + }), + { description: "Read a repository package", mimeType: "text/plain" }, + async (uri) => ({ + contents: [{ uri: uri.href, text: `package:${uri.href}`, mimeType: "text/plain" }], + }), +); + +server.registerResource( + "Repository query", + new ResourceTemplate("repo://{tenant}/items{?owner,region}", { + list: undefined, + ...(completionsEnabled + ? { + complete: { + tenant: (value: string) => + ["acme", "other"].filter((tenant) => tenant.startsWith(value)), + owner: (value: string) => + ["caplets", "other"].filter((owner) => owner.startsWith(value)), + region: (value: string, context?: { arguments?: Record }) => + (context?.arguments?.tenant === "acme" ? ["eu", "us"] : ["unknown"]).filter( + (region) => region.startsWith(value), + ), + }, + } + : {}), + }), + { description: "Read repository query results", mimeType: "text/plain" }, + async (uri) => ({ + contents: [{ uri: uri.href, text: `query:${uri.href}`, mimeType: "text/plain" }], + }), +); + server.registerPrompt( "review_issue", { description: "Review an issue before implementation.", - argsSchema: { issueId: z.string().describe("Issue ID") }, + argsSchema: { + owner: z.string().optional(), + issueId: completionsEnabled + ? completable(z.string().describe("Issue ID"), (value, context) => + (context?.arguments?.owner === "caplets" ? ["CAP-123"] : ["123", "124"]).filter( + (issueId) => issueId.startsWith(value), + ), + ) + : z.string().describe("Issue ID"), + }, }, ({ issueId }) => ({ messages: [{ role: "user", content: { type: "text", text: `Review ${issueId}` } }], diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index 7d1055c9..7310211b 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -695,6 +695,8 @@ describe("RemoteNativeCapletsService", () => { name: "Remote", title: "Remote", description: "Remote Code Mode handle.", + useWhen: "Use for remote repository work.", + avoidWhen: "Avoid for local-only operations.", inputSchema: { type: "object", additionalProperties: true }, schemaHash: "hash-code-mode", capletId: "remote", @@ -713,7 +715,18 @@ describe("RemoteNativeCapletsService", () => { pollIntervalMs: 60_000, }); - await remote.listTools(); + const tools = await remote.listTools(); + expect(tools).toEqual([ + expect.objectContaining({ + codeModeCaplets: [ + expect.objectContaining({ + capletId: "remote", + useWhen: "Use for remote repository work.", + avoidWhen: "Avoid for local-only operations.", + }), + ], + }), + ]); await expect(remote.callTool("remote", { operation: "inspect" })).resolves.toEqual({ invoked: true, }); @@ -2553,6 +2566,7 @@ describe("createNativeCapletsService remote mode", () => { projectConfigPath, writeErr, }); + await waitForInitialProjection(service); expect(configuredCapletIds(service.listTools())).toEqual(["local"]); expect(writeErr).toHaveBeenCalledTimes(1); @@ -3038,6 +3052,7 @@ describe("createNativeCapletsService remote mode", () => { configPath, projectConfigPath, }); + await waitForInitialProjection(service); try { expect(service.listTools()).toContainEqual( @@ -4074,7 +4089,7 @@ describe("createNativeCapletsService remote mode", () => { ); expect(bodies[0]).toMatchObject({ projectRoot, - allowedCapletIds: ["local", "code_mode"], + allowedCapletIds: [], }); expect(fetch).toHaveBeenCalledWith( new URL("http://127.0.0.1:5387/v1/attach/project-bindings/binding_1/session"), @@ -4428,6 +4443,15 @@ describe("createNativeCapletsService remote mode", () => { }); }); +async function waitForInitialProjection(service: NativeCapletsService): Promise { + await new Promise((resolve) => { + const unsubscribe = service.onToolsChanged(() => { + unsubscribe(); + resolve(); + }); + }); +} + function tempConfig(config: unknown) { const dir = mkdtempSync(join(tmpdir(), "caplets-native-remote-")); const userDir = join(dir, "user"); diff --git a/packages/core/test/native.test.ts b/packages/core/test/native.test.ts index 56dadc64..52d12ccb 100644 --- a/packages/core/test/native.test.ts +++ b/packages/core/test/native.test.ts @@ -9,7 +9,9 @@ import { nativeCapletPromptGuidance, nativeCapletToolName, nativeCapletsSystemGuidance, + type NativeCapletsService, } from "../src/native"; +import { CapletsEngine, type ResolvedExposureProjection } from "../src/engine"; import { recordTelemetryNoticeShown } from "../src/telemetry"; import { FileVaultStore } from "../src/vault"; @@ -59,6 +61,8 @@ describe("native Caplets service", () => { }); dirs.push(dir); const service = createNativeCapletsService({ configPath, projectConfigPath }); + expect(service.listTools()).toEqual([]); + await waitForInitialProjection(service); try { const tools = service.listTools(); @@ -124,6 +128,7 @@ describe("native Caplets service", () => { }); dirs.push(dir); const service = createNativeCapletsService({ configPath, projectConfigPath }); + await waitForInitialProjection(service); try { const result = await service.execute("alpha", { operation: "inspect" }); @@ -181,6 +186,7 @@ describe("native Caplets service", () => { telemetryEnv: {}, telemetryDispatcher: { capture, shutdown: vi.fn() }, }); + await waitForInitialProjection(service); try { await service.execute("alpha", { operation: "inspect" }); @@ -211,6 +217,7 @@ describe("native Caplets service", () => { telemetryEnv: {}, telemetryDispatcher: { capture, shutdown: vi.fn() }, }); + await waitForInitialProjection(service); try { await service.execute("alpha", { operation: "inspect" }); @@ -246,8 +253,10 @@ describe("native Caplets service", () => { process.env.XDG_STATE_HOME = join(dir, "state"); const ungranted = createNativeCapletsService({ configPath, projectConfigPath }); + await waitForInitialProjection(ungranted); try { expect(ungranted.listTools().map((tool) => tool.caplet)).not.toContain("github"); + expect(ungranted.listTools().map((tool) => tool.caplet)).not.toContain("code_mode"); } finally { await ungranted.close(); } @@ -262,6 +271,7 @@ describe("native Caplets service", () => { }); const granted = createNativeCapletsService({ configPath, projectConfigPath }); + await waitForInitialProjection(granted); try { expect(granted.listTools()).toEqual( expect.arrayContaining([ @@ -269,6 +279,7 @@ describe("native Caplets service", () => { caplet: "github", toolName: "caplets__github", }), + expect.objectContaining({ caplet: "code_mode" }), ]), ); } finally { @@ -285,6 +296,9 @@ describe("native Caplets service", () => { exposure: "direct", baseUrl: "http://127.0.0.1:1", auth: { type: "none" }, + useWhen: "Use when the service health is needed.", + avoidWhen: "Avoid for mutation requests.", + shadowing: "namespace", actions: { ping: { method: "GET", @@ -301,6 +315,7 @@ describe("native Caplets service", () => { }); dirs.push(dir); const service = createNativeCapletsService({ configPath, projectConfigPath }); + await waitForInitialProjection(service); try { expect(service.listTools()).toEqual([ @@ -313,6 +328,9 @@ describe("native Caplets service", () => { type: "object", properties: { verbose: { type: "boolean" } }, }, + useWhen: "Use when the service health is needed.", + avoidWhen: "Avoid for mutation requests.", + shadowing: "namespace", }), ]); } finally { @@ -320,6 +338,32 @@ describe("native Caplets service", () => { } }); + it("omits native completion when direct MCP does not advertise that capability", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + docs: { + name: "Docs", + description: "MCP prompts and resources without completion.", + exposure: "direct", + command: process.execPath, + args: ["--import", tsxImport, join(fixturesDir, "stdio-server.ts"), "--no-completions"], + }, + }, + }); + dirs.push(dir); + const service = createNativeCapletsService({ configPath, projectConfigPath, watch: false }); + await waitForInitialProjection(service); + + try { + expect(service.listTools().map((tool) => tool.caplet)).toEqual( + expect.arrayContaining(["docs__list_prompts", "docs__get_prompt"]), + ); + expect(service.listTools().map((tool) => tool.caplet)).not.toContain("docs__complete"); + } finally { + await service.close(); + } + }); + it("returns local HTTP artifacts with an absolute managed path in the native service", async () => { const http = await startPdfServer(); let artifactCallDir: string | undefined; @@ -338,6 +382,7 @@ describe("native Caplets service", () => { }); dirs.push(dir); const service = createNativeCapletsService({ configPath, projectConfigPath }); + await waitForInitialProjection(service); try { expect(service.listTools()).toEqual( @@ -397,6 +442,7 @@ describe("native Caplets service", () => { projectConfigPath, projectRoot, }); + await waitForInitialProjection(service); try { expect(service.listTools()).toEqual( @@ -497,8 +543,20 @@ describe("native Caplets service", () => { "fixture__echo", "fixture__list_resources", "fixture__get_prompt", + "fixture__complete", ]), ); + expect( + service.listTools().find((tool) => tool.caplet === "fixture__complete")?.inputSchema, + ).toMatchObject({ + properties: { + context: { + properties: { + arguments: { additionalProperties: { type: "string" } }, + }, + }, + }, + }); await expect(service.execute("fixture__echo", { message: "cold" })).resolves.toMatchObject({ structuredContent: { message: "cold" }, @@ -510,6 +568,15 @@ describe("native Caplets service", () => { }), }, }); + await expect( + service.execute("fixture__complete", { + ref: { type: "resourceTemplate", uri: "repo://{owner}/{name}{?region}" }, + argument: { name: "name", value: "co" }, + context: { arguments: { owner: "caplets", region: "eu" } }, + }), + ).resolves.toMatchObject({ + completion: { values: ["core"] }, + }); } finally { await service.close(); } @@ -600,6 +667,7 @@ describe("native Caplets service", () => { }); dirs.push(dir); const service = createNativeCapletsService({ configPath, projectConfigPath }); + await waitForInitialProjection(service); try { expect(service.listTools().map((tool) => tool.toolName)).toEqual(["caplets__code_mode"]); @@ -608,6 +676,218 @@ describe("native Caplets service", () => { } }); + it("removes Code Mode identities after refreshed discovery fails", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + httpApis: { + status: { + name: "Status HTTP", + description: "Call status over HTTP.", + exposure: "code_mode", + baseUrl: "http://127.0.0.1:1", + auth: { type: "none" }, + actions: { ping: { method: "GET", path: "/ping" } }, + }, + }, + }); + dirs.push(dir); + const service = createNativeCapletsService({ configPath, projectConfigPath, watch: false }); + await waitForInitialProjection(service); + expect(service.listTools().map((tool) => tool.caplet)).toEqual(["code_mode"]); + const events: string[][] = []; + service.onToolsChanged((tools) => events.push(tools.map((tool) => tool.caplet))); + + try { + writeFileSync( + configPath, + JSON.stringify( + progressiveTestConfig({ + mcpServers: { + broken: { + name: "Broken MCP", + description: "Unavailable MCP.", + exposure: "direct_and_code_mode", + command: join(dir, "missing-command"), + }, + }, + }), + ), + ); + await expect(service.reload()).resolves.toBe(true); + + expect(service.listTools()).toEqual([]); + expect(events.length).toBeGreaterThanOrEqual(1); + expect(events.every((tools) => tools.length === 0)).toBe(true); + } finally { + await service.close(); + } + }); + + it("fails native execution closed until the current projection resolves and after refreshed discovery fails", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + alpha: { + name: "Alpha", + description: "Search alpha project documents.", + command: process.execPath, + }, + }, + }); + dirs.push(dir); + const initialProjection = Promise.withResolvers(); + const originalExposureProjection = CapletsEngine.prototype.exposureProjection; + let projectionCalls = 0; + const exposureProjection = vi + .spyOn(CapletsEngine.prototype, "exposureProjection") + .mockImplementation(async (): Promise => { + if (projectionCalls++ === 0) return await initialProjection.promise; + throw new Error("refreshed discovery failed"); + }); + const service = createNativeCapletsService({ configPath, projectConfigPath, watch: false }); + const createdEngine = exposureProjection.mock.instances[0] as CapletsEngine | undefined; + if (!createdEngine) throw new Error("expected native service to begin projection discovery"); + const execute = vi.spyOn(createdEngine, "execute"); + + try { + await expect(service.execute("alpha", { operation: "inspect" })).rejects.toMatchObject({ + code: "SERVER_UNAVAILABLE", + }); + await expect( + service.execute("code_mode", { code: "return await caplets.alpha.inspect();" }), + ).rejects.toMatchObject({ code: "SERVER_UNAVAILABLE" }); + expect(execute).not.toHaveBeenCalled(); + + const initialReady = waitForInitialProjection(service); + initialProjection.resolve( + await originalExposureProjection.call(createdEngine, { + discoverNonDirectMcpSurfaces: false, + }), + ); + await initialReady; + expect(configuredCapletIds(service.listTools())).toEqual(["alpha"]); + + await expect(service.reload()).resolves.toBe(true); + expect(service.listTools()).toEqual([]); + await expect(service.execute("alpha", { operation: "inspect" })).rejects.toMatchObject({ + code: "SERVER_UNAVAILABLE", + }); + await expect( + service.execute("code_mode", { code: "return await caplets.alpha.inspect();" }), + ).rejects.toMatchObject({ code: "SERVER_UNAVAILABLE" }); + expect(execute).not.toHaveBeenCalled(); + } finally { + await service.close(); + exposureProjection.mockRestore(); + } + }); + + it("keeps native projections latest-wins across overlapping refreshes", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + alpha: { + name: "Alpha", + description: "Search alpha project documents.", + command: process.execPath, + }, + }, + }); + dirs.push(dir); + const initialProjection = Promise.withResolvers(); + const olderProjection = Promise.withResolvers(); + const newerProjection = Promise.withResolvers(); + const originalExposureProjection = CapletsEngine.prototype.exposureProjection; + let projectionCalls = 0; + const exposureProjection = vi + .spyOn(CapletsEngine.prototype, "exposureProjection") + .mockImplementation(async (): Promise => { + switch (projectionCalls++) { + case 0: + return await initialProjection.promise; + case 1: + return await olderProjection.promise; + case 2: + return await newerProjection.promise; + default: + throw new Error("unexpected projection refresh"); + } + }); + const service = createNativeCapletsService({ configPath, projectConfigPath, watch: false }); + const createdEngine = exposureProjection.mock.instances[0] as CapletsEngine | undefined; + if (!createdEngine) throw new Error("expected native service to begin projection discovery"); + + try { + const initialReady = waitForInitialProjection(service); + initialProjection.resolve( + await originalExposureProjection.call(createdEngine, { + discoverNonDirectMcpSurfaces: false, + }), + ); + await initialReady; + const events: string[][] = []; + service.onToolsChanged((tools) => events.push(configuredCapletIds(tools))); + + writeFileSync( + configPath, + JSON.stringify( + progressiveTestConfig({ + mcpServers: { + beta: { + name: "Beta", + description: "Search beta project documents.", + command: process.execPath, + }, + }, + }), + ), + ); + const olderReload = service.reload(); + await expect.poll(() => projectionCalls).toBe(2); + const olderResolved = await originalExposureProjection.call(createdEngine, { + discoverNonDirectMcpSurfaces: false, + }); + + writeFileSync( + configPath, + JSON.stringify( + progressiveTestConfig({ + mcpServers: { + gamma: { + name: "Gamma", + description: "Search gamma project documents.", + command: process.execPath, + }, + }, + }), + ), + ); + const newerReload = service.reload(); + await expect.poll(() => projectionCalls).toBe(3); + newerProjection.resolve( + await originalExposureProjection.call(createdEngine, { + discoverNonDirectMcpSurfaces: false, + }), + ); + await expect(newerReload).resolves.toBe(true); + + olderProjection.resolve({ + ...olderResolved, + generation: createdEngine.currentExposureGeneration(), + }); + await expect(olderReload).resolves.toBe(true); + + expect(configuredCapletIds(service.listTools())).toEqual(["gamma"]); + expect(events).toEqual([["gamma"]]); + const execute = vi.spyOn(createdEngine, "execute").mockResolvedValue({ ok: true }); + await expect(service.execute("gamma", { operation: "inspect" })).resolves.toEqual({ + ok: true, + }); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledWith("gamma", { operation: "inspect" }); + } finally { + await service.close(); + exposureProjection.mockRestore(); + } + }); + it("provides code-only Caplets as handles inside Code Mode", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ httpApis: { @@ -623,6 +903,7 @@ describe("native Caplets service", () => { }); dirs.push(dir); const service = createNativeCapletsService({ configPath, projectConfigPath }); + await waitForInitialProjection(service); try { const result = await service.execute("code_mode", { @@ -675,6 +956,7 @@ describe("native Caplets service", () => { }); dirs.push(dir); const service = createNativeCapletsService({ configPath, projectConfigPath }); + await waitForInitialProjection(service); try { await expect(service.execute("code_mode", { timeoutMs: 1_000 })).resolves.toMatchObject({ @@ -710,6 +992,7 @@ describe("native Caplets service", () => { }); dirs.push(dir); const service = createNativeCapletsService({ configPath, projectConfigPath }); + await waitForInitialProjection(service); try { const result = await service.execute("missing", { operation: "inspect" }); @@ -768,6 +1051,7 @@ describe("native Caplets service", () => { }); dirs.push(dir); const service = createNativeCapletsService({ configPath, projectConfigPath, watch: false }); + await waitForInitialProjection(service); try { expect(configuredCapletIds(service.listTools())).toEqual(["alpha"]); @@ -808,6 +1092,7 @@ describe("native Caplets service", () => { ); dirs.push(dir); const service = createNativeCapletsService({ configPath, projectConfigPath, watch: false }); + await waitForInitialProjection(service); try { expect(service.listTools().map((tool) => tool.caplet)).toEqual(["code_mode"]); @@ -834,6 +1119,7 @@ describe("native Caplets service", () => { watch: false, writeErr: (value) => errors.push(value), }); + await waitForInitialProjection(service); const events: string[][] = []; const unsubscribe = service.onToolsChanged((tools) => { events.push(configuredCapletIds(tools)); @@ -860,7 +1146,7 @@ describe("native Caplets service", () => { ), ); await expect(service.reload()).resolves.toBe(true); - expect(events).toEqual([["gamma"]]); + expect(events).toEqual([[], ["gamma"]]); unsubscribe(); writeFileSync( @@ -878,7 +1164,7 @@ describe("native Caplets service", () => { ), ); await expect(service.reload()).resolves.toBe(true); - expect(events).toEqual([["gamma"]]); + expect(events).toEqual([[], ["gamma"]]); } finally { await service.close(); } @@ -897,6 +1183,7 @@ describe("native Caplets service", () => { }); dirs.push(dir); const service = createNativeCapletsService({ configPath, projectConfigPath, watch: false }); + await waitForInitialProjection(service); const events: string[][] = []; service.onToolsChanged((tools) => { events.push(configuredCapletIds(tools)); @@ -921,6 +1208,7 @@ describe("native Caplets service", () => { await expect(service.reload()).resolves.toBe(true); expect(events).toEqual([ + [], expect.arrayContaining(["fixture__echo", "fixture__list_resources", "fixture__get_prompt"]), ]); } finally { @@ -951,6 +1239,7 @@ describe("native Caplets service", () => { async () => { throw new Error("close failed"); }; + await waitForInitialProjection(service); const events: string[][] = []; service.onToolsChanged((tools) => { events.push(configuredCapletIds(tools)); @@ -974,7 +1263,7 @@ describe("native Caplets service", () => { ); await expect(service.reload()).resolves.toBe(false); - expect(events).toEqual([["beta"]]); + expect(events).toEqual([[], ["beta"]]); expect(errors.join("")).toContain("backend invalidation failed"); } finally { await service.close(); @@ -997,6 +1286,7 @@ describe("native Caplets service", () => { projectConfigPath, watchDebounceMs: 10, }); + await waitForInitialProjection(service); const events: string[][] = []; service.onToolsChanged((tools) => { events.push(configuredCapletIds(tools)); @@ -1048,6 +1338,15 @@ describe("native Caplets service", () => { } }); +async function waitForInitialProjection(service: NativeCapletsService): Promise { + await new Promise((resolve) => { + const unsubscribe = service.onToolsChanged(() => { + unsubscribe(); + resolve(); + }); + }); +} + async function startPdfServer(): Promise<{ baseUrl: string; close: () => Promise }> { const server = createServer((_request, response) => { response.setHeader("content-type", "application/pdf"); diff --git a/packages/core/test/runtime.test.ts b/packages/core/test/runtime.test.ts index 9b0a495b..61a815fe 100644 --- a/packages/core/test/runtime.test.ts +++ b/packages/core/test/runtime.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; import { isAbsolute, join } from "node:path"; import type { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport"; import { nativeCapletToolName } from "../src/native"; import { CapletsRuntime } from "../src/runtime"; @@ -35,6 +37,7 @@ describe("CapletsRuntime", () => { dirs.push(dir); const server = mockServer(); const runtime = new CapletsRuntime({ configPath, projectConfigPath, server }); + await connectRuntime(runtime); expect(runtime.registeredToolIds()).toEqual(["alpha"]); expect(server.registerTool).toHaveBeenCalledTimes(2); @@ -57,6 +60,7 @@ describe("CapletsRuntime", () => { dirs.push(dir); const server = mockServer(); const runtime = new CapletsRuntime({ configPath, projectConfigPath, server }); + await connectRuntime(runtime); try { expect(runtime.registeredToolIds()).toEqual(["git-hub"]); @@ -81,6 +85,7 @@ describe("CapletsRuntime", () => { dirs.push(dir); const server = mockServer(); const runtime = new CapletsRuntime({ configPath, projectConfigPath, server }); + await connectRuntime(runtime); expect(runtime.registeredToolIds()).toEqual(["status"]); expect(server.registerTool).toHaveBeenCalledWith( @@ -115,6 +120,7 @@ describe("CapletsRuntime", () => { artifactDir, server, }); + await connectRuntime(runtime); try { const handler = server.handlers.get("status"); @@ -174,6 +180,7 @@ describe("CapletsRuntime", () => { dirs.push(dir); const server = mockServer(); const runtime = new CapletsRuntime({ configPath, projectConfigPath, server }); + await connectRuntime(runtime); expect(runtime.registeredToolIds()).toEqual(["repo"]); expect(server.registerTool).toHaveBeenCalledWith( @@ -186,18 +193,39 @@ describe("CapletsRuntime", () => { }); it("registers Caplet set Caplets", async () => { + const childDir = mkdtempSync(join(tmpdir(), "caplets-runtime-child-")); + dirs.push(childDir); + const childConfigPath = join(childDir, "config.json"); + writeFileSync( + childConfigPath, + JSON.stringify({ + cliTools: { + echoes: { + name: "Echoes", + description: "Echo child operations.", + actions: { + echo: { + command: process.execPath, + args: ["--version"], + }, + }, + }, + }, + }), + ); const { dir, configPath, projectConfigPath } = tempConfig({ capletSets: { nested: { name: "Nested Caplets", description: "Expose child Caplets through a nested collection.", - capletsRoot: join(tmpdir(), "caplets-child"), + configPath: childConfigPath, }, }, }); dirs.push(dir); const server = mockServer(); const runtime = new CapletsRuntime({ configPath, projectConfigPath, server }); + await connectRuntime(runtime); expect(runtime.registeredToolIds()).toEqual(["nested"]); expect(server.registerTool).toHaveBeenCalledWith( @@ -222,6 +250,7 @@ describe("CapletsRuntime", () => { dirs.push(dir); const server = mockServer(); const runtime = new CapletsRuntime({ configPath, projectConfigPath, server }); + await connectRuntime(runtime); const alpha = server.registered.get("alpha")!; writeConfig(configPath, { @@ -287,6 +316,7 @@ describe("CapletsRuntime", () => { server, writeErr: (value) => errors.push(value), }); + await connectRuntime(runtime); writeFileSync(configPath, "{not json"); const reloaded = await runtime.reload(); @@ -318,6 +348,7 @@ describe("CapletsRuntime", () => { server, writeErr: (value) => errors.push(value), }); + await connectRuntime(runtime); const alpha = server.registered.get("alpha")!; const engine = (runtime as unknown as { engine: unknown }).engine; (engine as { invalidateChangedBackends: () => Promise }).invalidateChangedBackends = @@ -345,6 +376,57 @@ describe("CapletsRuntime", () => { await runtime.close(); }); + it("returns the engine reload outcome when direct surfaces cannot register", async () => { + const fixture = fileURLToPath(new URL("fixtures/stdio-server.ts", import.meta.url)); + const { dir, configPath, projectConfigPath } = tempConfig({ + options: { exposure: "direct" }, + httpApis: { + status: { + name: "Status HTTP", + description: "Check service status.", + exposure: "direct", + baseUrl: "http://127.0.0.1:1", + auth: { type: "none" }, + actions: { ping: { method: "GET", path: "/ping" } }, + }, + }, + }); + dirs.push(dir); + const server = mockServer(); + const errors: string[] = []; + const runtime = new CapletsRuntime({ + configPath, + projectConfigPath, + server, + writeErr: (value) => errors.push(value), + }); + await connectRuntime(runtime); + expect(runtime.registeredToolIds()).toEqual(["status__ping"]); + + writeConfig(configPath, { + options: { exposure: "direct" }, + mcpServers: { + docs: { + name: "Docs", + description: "Fixture direct MCP surface.", + command: process.execPath, + args: ["--import", import.meta.resolve("tsx"), fixture], + }, + }, + }); + + await expect(runtime.reload()).resolves.toBe(true); + + expect(server).not.toHaveProperty("registerResource"); + expect(server).not.toHaveProperty("registerPrompt"); + expect(runtime.registeredToolIds()).toEqual([]); + expect(server.registered).toEqual(new Map()); + expect(errors.join("")).toContain("Caplets exposure refresh failed"); + expect(errors.join("")).toContain("MCP server does not support resource registration"); + + await runtime.close(); + }); + it("delegates watched paths to the engine", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ mcpServers: { @@ -402,6 +484,10 @@ describe("CapletsRuntime", () => { } }); +async function connectRuntime(runtime: CapletsRuntime): Promise { + await runtime.connect({} as unknown as Transport); +} + function writeConfig(path: string, config: unknown): void { writeFileSync(path, JSON.stringify(progressiveTestConfig(config))); } diff --git a/packages/core/test/serve-session.test.ts b/packages/core/test/serve-session.test.ts index ea9bf7d6..2f1f3497 100644 --- a/packages/core/test/serve-session.test.ts +++ b/packages/core/test/serve-session.test.ts @@ -2,10 +2,24 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp"; +import { fileURLToPath } from "node:url"; +import { + ResourceTemplate, + type RegisteredPrompt, + type RegisteredResource, + type RegisteredResourceTemplate, + type RegisteredTool, +} from "@modelcontextprotocol/sdk/server/mcp"; +import { getCompleter } from "@modelcontextprotocol/sdk/server/completable"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CapletsEngine } from "../src/engine"; import { CapletsMcpSession } from "../src/serve/session"; +import type { ResolvedExposureProjection } from "../src/engine"; +import { + buildManifestExposureProjection, + type ExposureProjection, +} from "../src/exposure/projection"; +import { directPromptName, directResourceTemplateUri } from "../src/exposure/direct-names"; import { sanitizeRemoteEngineOptions } from "../src/serve/http"; import { connectMcpTestClient } from "./mcp-test-client"; @@ -29,6 +43,8 @@ describe("CapletsMcpSession", () => { const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); const server = mockServer(); const session = new CapletsMcpSession(engine, { server }); + expect(session.registeredToolIds()).toEqual([]); + await session.refreshExposure(); expect(session.registeredToolIds()).toEqual(["alpha"]); expect(server.registerTool).toHaveBeenCalledTimes(2); @@ -72,6 +88,7 @@ describe("CapletsMcpSession", () => { }); const server = mockServer(); const session = new CapletsMcpSession(engine, { server }); + await session.refreshExposure(); expect(session.registeredToolIds()).toEqual(["alpha"]); expect(server.registered.get("alpha")).toBeDefined(); @@ -90,6 +107,7 @@ describe("CapletsMcpSession", () => { const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); const server = mockServer(); const session = new CapletsMcpSession(engine, { server }); + await session.refreshExposure(); const alpha = server.registered.get("alpha")!; const codeMode = server.registered.get("code_mode")!; @@ -105,6 +123,7 @@ describe("CapletsMcpSession", () => { }, }); await engine.reload(); + await session.refreshExposure(); expect(alpha.remove).toHaveBeenCalledTimes(1); expect(codeMode.update).toHaveBeenCalledWith( @@ -124,6 +143,179 @@ describe("CapletsMcpSession", () => { await engine.close(); }); + it("fails stale callbacks closed until the matching projection resolves", async () => { + const harness = projectionEngineHarness(); + harness.enqueueResolved(0, progressiveProjection("alpha", "Alpha")); + const server = mockServer(); + const session = new CapletsMcpSession(harness.engine, { server }); + await session.refreshExposure(); + const oldCallback = server.handlers.get("alpha"); + const alpha = server.registered.get("alpha"); + expect(oldCallback).toBeDefined(); + expect(alpha).toBeDefined(); + + harness.advanceGeneration(); + await expect(oldCallback?.({ operation: "inspect" })).rejects.toMatchObject({ + code: "SERVER_UNAVAILABLE", + }); + expect(harness.execute).not.toHaveBeenCalled(); + + harness.enqueueResolved(1, progressiveProjection("alpha", "Alpha v2")); + await session.refreshExposure(); + const update = lastToolUpdate(alpha); + const currentCallback = update.callback; + expect(currentCallback).toEqual(expect.any(Function)); + await (currentCallback as (request: unknown) => Promise)({ operation: "inspect" }); + expect(harness.execute).toHaveBeenCalledWith("alpha", { operation: "inspect" }); + + await session.close(); + }); + + it("invalidates prior callbacks when availability changes within one config generation", async () => { + const harness = projectionEngineHarness(); + harness.enqueueResolved(0, progressiveProjection("alpha", "Alpha", true)); + const server = mockServer(); + const session = new CapletsMcpSession(harness.engine, { server }); + await session.refreshExposure(); + const progressiveCallback = server.handlers.get("alpha"); + const codeModeCallback = server.handlers.get("code_mode"); + expect(progressiveCallback).toBeDefined(); + expect(codeModeCallback).toBeDefined(); + + harness.enqueueResolved( + 0, + buildManifestExposureProjection({ + caplets: [], + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + completions: [], + codeModeCaplets: [], + }), + ); + await session.refreshExposure(); + + await expect(progressiveCallback?.({ operation: "inspect" })).rejects.toMatchObject({ + code: "SERVER_UNAVAILABLE", + }); + await expect( + codeModeCallback?.({ code: "return await caplets.alpha.inspect();" }), + ).rejects.toMatchObject({ + code: "SERVER_UNAVAILABLE", + }); + expect(server.registered.get("code_mode")).toBeUndefined(); + expect(harness.execute).not.toHaveBeenCalled(); + + await session.close(); + }); + + it("discards an older projection when overlapping refreshes resolve out of order", async () => { + const harness = projectionEngineHarness(); + harness.enqueueResolved(0, progressiveProjection("alpha", "Initial")); + const server = mockServer(); + const session = new CapletsMcpSession(harness.engine, { server }); + await session.refreshExposure(); + const alpha = server.registered.get("alpha"); + expect(alpha).toBeDefined(); + + const older = Promise.withResolvers(); + const newer = Promise.withResolvers(); + harness.enqueue(older.promise); + harness.enqueue(newer.promise); + const olderRefresh = session.refreshExposure(); + const newerRefresh = session.refreshExposure(); + newer.resolve({ generation: 0, projection: progressiveProjection("alpha", "Newest") }); + await newerRefresh; + older.resolve({ generation: 0, projection: progressiveProjection("alpha", "Older") }); + await olderRefresh; + + expect(lastToolUpdate(alpha)).toMatchObject({ title: "Newest" }); + expect(toolUpdates(alpha)).not.toEqual( + expect.arrayContaining([expect.objectContaining({ title: "Older" })]), + ); + + await session.close(); + }); + + it("renders resource, template, and prompt registrations directly from projection entries", async () => { + const harness = projectionEngineHarness(); + harness.enqueueResolved(0, directSurfaceProjection()); + const server = mockServer(); + const session = new CapletsMcpSession(harness.engine, { server }); + await session.refreshExposure(); + + expect(server.registerResource).toHaveBeenCalledTimes(2); + expect(server.registerPrompt).toHaveBeenCalledTimes(1); + await server.resourceHandlers.get("README")?.(); + await server.resourceHandlers.get("docs:File")?.( + new URL("caplets://docs/resources/file%3A%2F%2F%2Fguide.md"), + ); + await server.promptHandlers.get("docs__review")?.({ topic: "architecture" }); + const resourceCompletion = server.resourceTemplates + .get("docs:File") + ?.completeCallback("encodedUri"); + const promptArgsSchema = server.promptDefinitions.get("docs__review")?.argsSchema; + const topicSchema = isRecord(promptArgsSchema) + ? (promptArgsSchema.topic as { + description?: string; + safeParse(value: unknown): { success: boolean }; + }) + : undefined; + const promptCompletion = isRecord(promptArgsSchema) + ? getCompleter(promptArgsSchema.topic as Parameters[0]) + : undefined; + await expect(resourceCompletion?.("READ")).resolves.toEqual(["file:///README.md"]); + await expect(promptCompletion?.("arch")).resolves.toEqual(["architecture"]); + expect(topicSchema?.description).toBe("Topic to summarize."); + expect(topicSchema?.safeParse(undefined).success).toBe(false); + + expect(harness.readDirectResource).toHaveBeenNthCalledWith(1, "docs", "file:///README.md"); + expect(harness.readDirectResource).toHaveBeenNthCalledWith(2, "docs", "file:///guide.md"); + expect(harness.getDirectPrompt).toHaveBeenCalledWith("docs", "review", { + topic: "architecture", + }); + expect(harness.completeDirectReference).toHaveBeenNthCalledWith( + 1, + "docs", + { type: "resourceTemplate", uri: "file:///{path}" }, + { name: "path", value: "READ" }, + { arguments: {} }, + ); + expect(harness.completeDirectReference).toHaveBeenNthCalledWith( + 2, + "docs", + { type: "prompt", name: "review" }, + { name: "topic", value: "arch" }, + undefined, + ); + + await session.close(); + }); + + it("fails closed when projection registration cannot reconcile atomically", async () => { + const harness = projectionEngineHarness(); + harness.enqueueResolved(0, progressiveProjection("alpha", "Alpha")); + const server = mockServer(); + const session = new CapletsMcpSession(harness.engine, { server }); + await session.refreshExposure(); + const oldCallback = server.handlers.get("alpha"); + server.registerResource.mockImplementationOnce(() => { + throw new Error("resource registration failed"); + }); + harness.enqueueResolved(0, directSurfaceProjection()); + + await expect(session.refreshExposure()).rejects.toThrow("resource registration failed"); + + expect(session.registeredToolIds()).toEqual([]); + expect(server.registered.get("code_mode")).toBeUndefined(); + await expect(oldCallback?.({ operation: "inspect" })).rejects.toMatchObject({ + code: "SERVER_UNAVAILABLE", + }); + + await session.close(); + }); + it("registers direct operation tools without progressive wrapper or Code Mode", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ httpApis: { @@ -166,6 +358,94 @@ describe("CapletsMcpSession", () => { await engine.close(); }); + it("forwards direct MCP resource and prompt completions through projected registrations", async () => { + const fixture = fileURLToPath(new URL("fixtures/stdio-server.ts", import.meta.url)); + const { dir, configPath, projectConfigPath } = tempConfig({ + options: { exposure: "direct" }, + mcpServers: { + docs: { + name: "Docs", + description: "Fixture direct MCP surface.", + command: process.execPath, + args: ["--import", import.meta.resolve("tsx"), fixture], + }, + }, + }); + dirs.push(dir); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + const session = new CapletsMcpSession(engine); + const client = await connectMcpTestClient(session); + + try { + const resource = await client.complete({ + ref: { + type: "ref/resource", + uri: directResourceTemplateUri("docs", "file:///repo/{path}"), + }, + argument: { name: "encodedUri", value: "READ" }, + }); + const packageOwner = await client.complete({ + ref: { + type: "ref/resource", + uri: directResourceTemplateUri("docs", "repo://{owner}/{name}{?region}"), + }, + argument: { name: "encodedUri", value: "repo://ca" }, + }); + const packageName = await client.complete({ + ref: { + type: "ref/resource", + uri: directResourceTemplateUri("docs", "repo://{owner}/{name}{?region}"), + }, + argument: { name: "encodedUri", value: "repo://caplets/co" }, + context: { arguments: { region: "eu" } }, + }); + const skippedQueryOwner = await client.complete({ + ref: { + type: "ref/resource", + uri: directResourceTemplateUri("docs", "repo://{tenant}/items{?owner,region}"), + }, + argument: { name: "encodedUri", value: "repo://acme/items?region=e" }, + }); + const incompleteEscape = await client.complete({ + ref: { + type: "ref/resource", + uri: directResourceTemplateUri("docs", "repo://{tenant}/items{?owner,region}"), + }, + argument: { name: "encodedUri", value: "repo://acme/items?region=%" }, + }); + const encodedOwner = await client.complete({ + ref: { + type: "ref/resource", + uri: directResourceTemplateUri("docs", "repo://{owner}/{name}{?region}"), + }, + argument: { name: "encodedUri", value: "repo://caplets%20inc/co" }, + context: { arguments: { region: "eu" } }, + }); + const prompt = await client.complete({ + ref: { type: "ref/prompt", name: directPromptName("docs", "review_issue") }, + argument: { name: "issueId", value: "12" }, + }); + const contextualPrompt = await client.complete({ + ref: { type: "ref/prompt", name: directPromptName("docs", "review_issue") }, + argument: { name: "issueId", value: "CAP" }, + context: { arguments: { owner: "caplets" } }, + }); + + expect(resource.completion.values).toEqual(["file:///repo/README.md"]); + expect(prompt.completion.values).toEqual(["123", "124"]); + expect(packageOwner.completion.values).toEqual(["repo://caplets/"]); + expect(packageName.completion.values).toEqual(["repo://caplets/core?region=eu"]); + expect(skippedQueryOwner.completion.values).toEqual(["repo://acme/items?region=eu"]); + expect(incompleteEscape.completion.values).toEqual([]); + expect(encodedOwner.completion.values).toEqual(["repo://caplets%20inc/core?region=eu"]); + expect(contextualPrompt.completion.values).toEqual(["CAP-123"]); + } finally { + await client.close(); + await session.close(); + await engine.close(); + } + }); + it("validates remote artifact references through a real direct MCP registration", async () => { const http = await startPdfServer(); try { @@ -325,6 +605,154 @@ describe("CapletsMcpSession", () => { await google.close(); } }); + function progressiveProjection( + capletId: string, + title: string, + codeMode = false, + ): ExposureProjection { + return buildManifestExposureProjection({ + caplets: [ + { + kind: "caplet", + name: title, + title, + capletId, + inputSchema: { + type: "object", + properties: { operation: { type: "string" } }, + required: ["operation"], + additionalProperties: false, + }, + shadowing: "forbid", + }, + ], + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + completions: [], + codeModeCaplets: codeMode + ? [ + { + kind: "caplet", + name: title, + title, + capletId, + shadowing: "forbid", + }, + ] + : [], + }); + } + + function directSurfaceProjection(): ExposureProjection { + return buildManifestExposureProjection({ + caplets: [], + tools: [], + resources: [ + { + kind: "resource", + capletId: "docs", + uri: "caplets://docs/resources/file%3A%2F%2F%2FREADME.md", + downstreamUri: "file:///README.md", + title: "README", + mimeType: "text/markdown", + size: 42, + shadowing: "forbid", + }, + ], + resourceTemplates: [ + { + kind: "resourceTemplate", + capletId: "docs", + uriTemplate: "caplets://docs/resources/{encodedUri}", + downstreamUriTemplate: "file:///{path}", + title: "File", + mimeType: "text/plain", + shadowing: "forbid", + }, + ], + prompts: [ + { + kind: "prompt", + capletId: "docs", + name: "docs__review", + downstreamName: "review", + title: "Review", + inputSchema: { + arguments: [{ name: "topic", description: "Topic to summarize.", required: true }], + }, + shadowing: "forbid", + }, + ], + completions: [ + { + kind: "completion", + capletId: "docs", + name: "docs:complete", + shadowing: "forbid", + }, + ], + codeModeCaplets: [], + }); + } + + function projectionEngineHarness() { + let generation = 0; + const projections: Promise[] = []; + const execute = vi.fn(async () => ({ ok: true })); + const getDirectPrompt = vi.fn(async () => ({ messages: [] })); + const readDirectResource = vi.fn(async () => ({ + contents: [{ uri: "file:///README.md", text: "README" }], + })); + const completeDirectReference = vi.fn( + async ( + _serverId: string, + ref: { type: "prompt"; name: string } | { type: "resourceTemplate"; uri: string }, + ) => (ref.type === "prompt" ? ["architecture"] : ["README.md"]), + ); + const engine = { + currentExposureGeneration: () => generation, + exposureProjection: async () => { + const next = projections.shift(); + if (!next) throw new Error("No queued exposure projection"); + return await next; + }, + onReload: () => () => undefined, + execute, + executeDirectTool: vi.fn(), + getDirectPrompt, + readDirectResource, + completeDirectReference, + captureCodeModeOutcome: vi.fn(), + } as unknown as CapletsEngine; + return { + engine, + execute, + getDirectPrompt, + readDirectResource, + completeDirectReference, + enqueue: (projection: Promise) => projections.push(projection), + enqueueResolved: (resolvedGeneration: number, projection: ExposureProjection) => + projections.push(Promise.resolve({ generation: resolvedGeneration, projection })), + advanceGeneration: () => { + generation += 1; + }, + }; + } + + function toolUpdates(tool: RegisteredTool | undefined): Record[] { + if (!tool) throw new Error("Expected registered tool"); + const update = tool.update as unknown as { mock: { calls: unknown[][] } }; + return update.mock.calls.flatMap((call) => (isRecord(call[0]) ? [call[0]] : [])); + } + + function lastToolUpdate(tool: RegisteredTool | undefined): Record { + const updates = toolUpdates(tool); + const update = updates.at(-1); + if (!update) throw new Error("Expected tool update"); + return update; + } }); function tempConfig(config: unknown): { @@ -358,10 +786,18 @@ function mockServer() { const registered = new Map(); const definitions = new Map>(); const handlers = new Map Promise>(); + const resourceHandlers = new Map Promise>(); + const promptHandlers = new Map Promise>(); + const resourceTemplates = new Map(); + const promptDefinitions = new Map>(); return { registered, definitions, handlers, + resourceHandlers, + promptHandlers, + resourceTemplates, + promptDefinitions, registerTool: vi.fn((name: string, ...args: unknown[]) => { const definition = args[0]; const handler = args[1]; @@ -380,8 +816,37 @@ function mockServer() { if (isRecord(definition)) definitions.set(name, definition); return tool; }), - registerResource: vi.fn(), - registerPrompt: vi.fn(), + registerResource: vi.fn((name: string, ...args: unknown[]) => { + const handler = args.at(-1); + const uriOrTemplate = args[0]; + if (uriOrTemplate instanceof ResourceTemplate) { + resourceTemplates.set(name, uriOrTemplate); + } + if (typeof handler === "function") { + resourceHandlers.set( + name, + async (uri) => await Reflect.apply(handler, undefined, uri ? [uri] : []), + ); + } + return { + remove: vi.fn(() => { + resourceHandlers.delete(name); + resourceTemplates.delete(name); + }), + } as unknown as RegisteredResource & RegisteredResourceTemplate; + }), + registerPrompt: vi.fn((name: string, definition: unknown, handler: unknown) => { + if (isRecord(definition)) promptDefinitions.set(name, definition); + if (typeof handler === "function") { + promptHandlers.set(name, async (args) => await Reflect.apply(handler, undefined, [args])); + } + return { + remove: vi.fn(() => { + promptHandlers.delete(name); + promptDefinitions.delete(name); + }), + } as unknown as RegisteredPrompt; + }), connect: vi.fn(async () => {}), close: vi.fn(async () => {}), }; From f329556c68f5a93dac84e4da1918cb916c73ff30 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 10 Jul 2026 04:47:57 -0400 Subject: [PATCH 4/9] refactor(core): expose structured Vault quarantine outcomes Keep recovery facts at the config seam so setup and doctor no longer parse warning prose. Preserve safe warning presentation while distinguishing recoverable quarantine from loader failure. --- packages/core/src/cli.ts | 33 ++-- packages/core/src/cli/doctor.ts | 44 ++--- packages/core/src/config.ts | 86 ++++++--- packages/core/test/cli.test.ts | 254 +++++++++++++++++++++++++- packages/core/test/config.test.ts | 248 ++++++++++++++++++++++++- packages/core/test/doctor-cli.test.ts | 253 ++++++++++++++++++++++++- 6 files changed, 857 insertions(+), 61 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 40b0ecfc..9d1b22c6 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -80,6 +80,7 @@ import { defaultUpdateCheckCacheDir, defaultUpdateCheckStateDir, defaultCapletsLockfilePath, + formatVaultRecoveryCommand, loadGlobalServeDefaults, loadConfigWithSources, loadLocalOverlayConfigWithSources, @@ -3073,7 +3074,7 @@ export function createProgram(io: CliIO = {}): Command { lockfilePath, }); await attachCatalogIndexingResults(result.installed, env); - attachVaultSetupResults(result.installed, target, io); + attachVaultSetupResults(result.installed, io); if (options.json) { writeOut(`${JSON.stringify(installJsonResult(result.installed), null, 2)}\n`); return; @@ -3094,7 +3095,7 @@ export function createProgram(io: CliIO = {}): Command { lockfilePath, }); await attachCatalogIndexingResults(result.installed, env); - attachVaultSetupResults(result.installed, target, io); + attachVaultSetupResults(result.installed, io); if (options.json) { writeOut(`${JSON.stringify(installJsonResult(result.installed), null, 2)}\n`); return; @@ -3168,7 +3169,7 @@ export function createProgram(io: CliIO = {}): Command { lockfilePath, }); await attachCatalogIndexingResults(result.installed, env); - attachVaultSetupResults(result.installed, target, io); + attachVaultSetupResults(result.installed, io); if (options.json) { writeOut(`${JSON.stringify(installJsonResult(result.installed), null, 2)}\n`); return; @@ -4325,12 +4326,10 @@ function writeCatalogIndexingNotice( function attachVaultSetupResults( installed: Array<{ id: string; vaultSetup?: unknown }>, - target: Exclude, io: CliIO, ): void { const statuses = vaultSetupStatusesForInstalled( installed.map((entry) => entry.id), - target, io, ); for (const entry of installed) { @@ -4338,11 +4337,7 @@ function attachVaultSetupResults( } } -function vaultSetupStatusesForInstalled( - ids: string[], - target: Exclude, - io: CliIO, -): Map { +function vaultSetupStatusesForInstalled(ids: string[], io: CliIO): Map { const statuses = new Map( ids.map((id) => [id, { status: "ready", recoveryCommands: [], messages: [] }]), ); @@ -4351,16 +4346,22 @@ function vaultSetupStatusesForInstalled( const configPath = resolveConfigPath(envConfigPath(env)); const projectConfigPath = envProjectConfigPath(env); const result = loadLocalOverlayConfigWithSources(configPath, projectConfigPath); + if ( + result.warnings.some((warning) => warning.type === undefined && warning.recoverable !== true) + ) { + for (const status of statuses.values()) { + status.status = "unknown"; + } + return statuses; + } for (const warning of result.warnings) { - if (!warning.recoverable || !warning.message.includes("Vault key")) continue; - const id = /^Caplet ([^ ]+) references /u.exec(warning.message)?.[1]; - if (!id || !statuses.has(id)) continue; - const status = statuses.get(id); + if (warning.type !== "vault-quarantine" || !statuses.has(warning.capletId)) continue; + const status = statuses.get(warning.capletId); if (!status) continue; status.status = "unresolved"; status.messages.push(warning.message); - const command = /run `([^`]+)`/u.exec(warning.message)?.[1]; - if (command && !status.recoveryCommands.includes(command)) { + const command = formatVaultRecoveryCommand(warning); + if (!status.recoveryCommands.includes(command)) { status.recoveryCommands.push(command); } } diff --git a/packages/core/src/cli/doctor.ts b/packages/core/src/cli/doctor.ts index d1b8e831..df4253af 100644 --- a/packages/core/src/cli/doctor.ts +++ b/packages/core/src/cli/doctor.ts @@ -29,7 +29,13 @@ import { resolveProjectConfigPath, } from "../config/paths"; import { FileObservedOutputShapeStore } from "../observed-output-shapes"; -import { loadConfig, loadLocalOverlayConfigWithSources, type CapletConfig } from "../config"; +import { + formatVaultRecoveryCommand, + loadConfig, + loadLocalOverlayConfigWithSources, + type CapletConfig, + type VaultQuarantineOutcome, +} from "../config"; import { resolveExposure } from "../exposure/policy"; import { daemonStatus, type DaemonOperationOptions } from "../daemon"; @@ -310,10 +316,23 @@ function resolveVaultSection( : resolveProjectConfigPath(cwd); try { const overlay = loadLocalOverlayConfigWithSources(configPath, projectConfigPath); + const loaderFailure = overlay.warnings.find( + (warning) => warning.type === undefined && warning.recoverable !== true, + ); + if (loaderFailure) { + return { ok: false, issues: [], message: loaderFailure.message }; + } const issues = overlay.warnings - .filter((warning) => warning.message.includes("Vault key")) - .map((warning) => vaultIssueFromWarning(warning.message, warning.path)) - .filter((issue): issue is NonNullable => issue !== undefined); + .filter((warning): warning is VaultQuarantineOutcome => warning.type === "vault-quarantine") + .map((warning) => ({ + capletId: warning.capletId, + reason: warning.reason, + key: warning.effectiveKey, + configPath: warning.path, + referencePath: warning.referencePath, + target: warning.target, + recoveryCommand: formatVaultRecoveryCommand(warning), + })); return { ok: issues.length === 0, issues }; } catch (error) { return { @@ -324,23 +343,6 @@ function resolveVaultSection( } } -function vaultIssueFromWarning(message: string, path: string) { - const match = message.match( - /^Caplet ([^ ]+) references ([^ ]+) Vault key ([^ ]+) at ([^;]+); run `([^`]+)`/u, - ); - if (!match) return undefined; - const recoveryCommand = match[5] ?? ""; - return { - capletId: match[1], - reason: match[2], - key: match[3], - configPath: path, - referencePath: match[4], - target: recoveryCommand.includes("--remote") ? "remote" : "global", - recoveryCommand, - }; -} - function projectBindingRecovery( remoteLogin: DoctorRemoteLoginSection, remote: Record, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 6e62689f..fee00819 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -390,13 +390,16 @@ export type ConfigWithSources = { shadows: Record; }; -export type LocalOverlayConfigWarning = { +type GenericLocalOverlayConfigWarning = { + type?: undefined; kind: ConfigSourceKind; path: string; message: string; recoverable?: boolean | undefined; }; +export type LocalOverlayConfigWarning = GenericLocalOverlayConfigWarning | VaultQuarantineOutcome; + export type LocalOverlayConfigWithSources = ConfigWithSources & { warnings: LocalOverlayConfigWarning[]; sourceFound: boolean; @@ -419,6 +422,21 @@ export type ConfigVaultResolution = origin: VaultConfigOrigin; }; +export type VaultQuarantineOutcome = { + type: "vault-quarantine"; + kind: ConfigSourceKind; + path: string; + message: string; + recoverable: true; + capletId: string; + referencePath: string; + referenceName: string; + storedKey?: string | undefined; + effectiveKey: string; + reason: Exclude["reason"]; + target: "global" | "remote"; +}; + export type ConfigVaultResolver = (reference: ConfigVaultReference) => ConfigVaultResolution; export type ConfigParseOptions = { @@ -2078,12 +2096,9 @@ function quarantineUnresolvedReferenceCaplets( }); } for (const issue of vaultIssues) { - warnings.push({ - kind, - path: capletSourcePath, - message: formatVaultReferenceWarning(id, issue, options.vaultRecoveryTarget), - recoverable: true, - }); + warnings.push( + vaultQuarantineOutcome(kind, capletSourcePath, id, issue, options.vaultRecoveryTarget), + ); } } } @@ -2213,22 +2228,51 @@ function unresolvedVaultReferencesInString( return issues; } -function formatVaultReferenceWarning( - id: string, +function vaultQuarantineOutcome( + kind: ConfigSourceKind, + path: string, + capletId: string, issue: VaultReferenceIssue, target: ConfigParseOptions["vaultRecoveryTarget"], -): string { - const key = issue.storedKey ?? issue.name; - const targetFlag = target === "remote" ? " --remote" : ""; - if (issue.reason === "invalid-key-source") { - return `Caplet ${id} references invalid-key-source Vault key ${key} at ${issue.path}; run \`caplets doctor\` for key-source details, then reload Caplets; skipping Caplet ${id}.`; - } - if (issue.reason === "missing") { - return `Caplet ${id} references missing Vault key ${key} at ${issue.path}; run \`caplets vault set ${key}${targetFlag}\`, then reload Caplets; skipping Caplet ${id}.`; - } - const remapFlag = key !== issue.name ? ` --as ${issue.name}` : ""; - const grantCommand = `caplets vault access grant ${key} ${id}${targetFlag}${remapFlag}`; - return `Caplet ${id} references ${issue.reason} Vault key ${key} at ${issue.path}; run \`${grantCommand}\` after setting the value, then reload Caplets; skipping Caplet ${id}.`; +): VaultQuarantineOutcome { + const outcome: VaultQuarantineOutcome = { + type: "vault-quarantine", + kind, + path, + message: "", + recoverable: true, + capletId, + referencePath: issue.path, + referenceName: issue.name, + ...(issue.storedKey ? { storedKey: issue.storedKey } : {}), + effectiveKey: issue.storedKey ?? issue.name, + reason: issue.reason, + target: target ?? "global", + }; + outcome.message = formatVaultReferenceWarning(outcome); + return outcome; +} + +function formatVaultReferenceWarning(outcome: VaultQuarantineOutcome): string { + const command = formatVaultRecoveryCommand(outcome); + if (outcome.reason === "invalid-key-source") { + return `Caplet ${outcome.capletId} references invalid-key-source Vault key ${outcome.effectiveKey} at ${outcome.referencePath}; run \`${command}\` for key-source details, then reload Caplets; skipping Caplet ${outcome.capletId}.`; + } + if (outcome.reason === "missing") { + return `Caplet ${outcome.capletId} references missing Vault key ${outcome.effectiveKey} at ${outcome.referencePath}; run \`${command}\`, then reload Caplets; skipping Caplet ${outcome.capletId}.`; + } + return `Caplet ${outcome.capletId} references ${outcome.reason} Vault key ${outcome.effectiveKey} at ${outcome.referencePath}; run \`${command}\` after setting the value, then reload Caplets; skipping Caplet ${outcome.capletId}.`; +} + +export function formatVaultRecoveryCommand(outcome: VaultQuarantineOutcome): string { + if (outcome.reason === "invalid-key-source") return "caplets doctor"; + const targetFlag = outcome.target === "remote" ? " --remote" : ""; + if (outcome.reason === "missing") { + return `caplets vault set ${outcome.effectiveKey}${targetFlag}`; + } + const remapFlag = + outcome.effectiveKey !== outcome.referenceName ? ` --as ${outcome.referenceName}` : ""; + return `caplets vault access grant ${outcome.effectiveKey} ${outcome.capletId}${targetFlag}${remapFlag}`; } export function defaultVaultResolver(store = new FileVaultStore()): ConfigVaultResolver { diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index 840f6177..24e30db4 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -22,7 +22,8 @@ import { readHiddenInputForTest, runCli, } from "../src/cli"; -import { loadConfig, parseConfig } from "../src/config"; +import { loadConfig, parseConfig, type VaultQuarantineOutcome } from "../src/config"; +import * as configModule from "../src/config"; import type { CapletsError } from "../src/errors"; import { readCapletsLockfile } from "../src/cli/lockfile"; import { readTokenBundle, writeTokenBundle } from "../src/auth"; @@ -2198,6 +2199,257 @@ describe("cli init", () => { rmSync(dir, { recursive: true, force: true }); } }); + it("uses structured Vault outcomes for install, restore, and update setup without parsing warning copy", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-setup-structured-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const configPath = join(dir, "user", "config.json"); + const projectConfigPath = join(projectRoot, ".caplets", "config.json"); + const out: string[] = []; + const cwd = process.cwd(); + const warnings: VaultQuarantineOutcome[] = [ + { + type: "vault-quarantine", + kind: "project-file" as const, + path: join(projectRoot, ".caplets", "github", "CAPLET.md"), + message: "Warning punctuation changed completely", + recoverable: true as const, + capletId: "github", + referencePath: "mcpServers.github.env.PRIMARY", + referenceName: "GH_TOKEN", + effectiveKey: "GH_TOKEN", + reason: "ungranted" as const, + target: "global" as const, + }, + { + type: "vault-quarantine", + kind: "project-file" as const, + path: join(projectRoot, ".caplets", "github", "CAPLET.md"), + message: "The human warning is deliberately unrelated to recovery facts", + recoverable: true as const, + capletId: "github", + referencePath: "mcpServers.github.env.SECONDARY", + referenceName: "GH_TOKEN", + effectiveKey: "GH_TOKEN", + reason: "ungranted" as const, + target: "global" as const, + }, + { + type: "vault-quarantine", + kind: "project-file" as const, + path: join(projectRoot, ".caplets", "github", "CAPLET.md"), + message: "No parser should need this message", + recoverable: true as const, + capletId: "github", + referencePath: "mcpServers.github.env.TERTIARY", + referenceName: "SECONDARY_TOKEN", + effectiveKey: "SECONDARY_TOKEN", + reason: "missing" as const, + target: "global" as const, + }, + ]; + const loader = vi.spyOn(configModule, "loadLocalOverlayConfigWithSources").mockReturnValue({ + config: parseConfig({}), + sources: {}, + shadows: {}, + sourceFound: true, + warnings, + }); + const env = { + ...process.env, + CAPLETS_CONFIG: configPath, + CAPLETS_PROJECT_CONFIG: projectConfigPath, + CAPLETS_DISABLE_CATALOG_INDEXING: "1", + }; + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); + + await runCli(["install", repo, "github", "filesystem", "--json"], { + env, + writeOut: (value) => out.push(value), + }); + const installed = JSON.parse(out.join("")); + expect(installed.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "github", + status: "installed", + vaultSetup: { + status: "unresolved", + recoveryCommands: [ + "caplets vault access grant GH_TOKEN github", + "caplets vault set SECONDARY_TOKEN", + ], + messages: warnings.map((warning) => warning.message), + }, + }), + expect.objectContaining({ + id: "filesystem", + status: "installed", + vaultSetup: { status: "ready", recoveryCommands: [], messages: [] }, + }), + ]), + ); + expect(existsSync(join(projectRoot, ".caplets", "github", "CAPLET.md"))).toBe(true); + + out.length = 0; + await runCli(["install", "github", "--json"], { + env, + writeOut: (value) => out.push(value), + }); + expect(JSON.parse(out.join(""))).toMatchObject({ + entries: [ + expect.objectContaining({ + vaultSetup: expect.objectContaining({ status: "unresolved" }), + }), + ], + }); + + out.length = 0; + await runCli(["update", "github", "--json"], { + env, + writeOut: (value) => out.push(value), + }); + expect(JSON.parse(out.join(""))).toMatchObject({ + entries: [ + expect.objectContaining({ + vaultSetup: expect.objectContaining({ status: "unresolved" }), + }), + ], + }); + + loader.mockImplementation(() => { + throw new Error("overlay loader unavailable"); + }); + out.length = 0; + await runCli(["update", "github", "filesystem", "--json"], { + env, + writeOut: (value) => out.push(value), + }); + const afterLoaderFailure = JSON.parse(out.join("")); + expect(afterLoaderFailure.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "github", + vaultSetup: expect.objectContaining({ status: "unknown" }), + }), + expect.objectContaining({ + id: "filesystem", + vaultSetup: expect.objectContaining({ status: "unknown" }), + }), + ]), + ); + } finally { + loader.mockRestore(); + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("marks every Setup status unknown when the best-effort overlay reports a loader failure", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-setup-loader-failure-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const configPath = join(dir, "user", "config.json"); + const projectConfigPath = join(projectRoot, ".caplets", "config.json"); + const out: string[] = []; + const cwd = process.cwd(); + try { + writeInstallableRepo(repo); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, "{ malformed JSON"); + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); + + await runCli(["install", repo, "github", "filesystem", "--json"], { + env: { + ...process.env, + CAPLETS_CONFIG: configPath, + CAPLETS_PROJECT_CONFIG: projectConfigPath, + CAPLETS_DISABLE_CATALOG_INDEXING: "1", + }, + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join("")).entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "github", + vaultSetup: expect.objectContaining({ status: "unknown" }), + }), + expect.objectContaining({ + id: "filesystem", + vaultSetup: expect.objectContaining({ status: "unknown" }), + }), + ]), + ); + expect(existsSync(join(projectRoot, ".caplets", "github", "CAPLET.md"))).toBe(true); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps Setup statuses ready for recoverable non-Vault overlay warnings", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-setup-recoverable-warning-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const configPath = join(dir, "user", "config.json"); + const projectConfigPath = join(projectRoot, ".caplets", "config.json"); + const missingEnvName = "CAPLETS_U4_RECOVERABLE_OVERLAY_WARNING"; + const originalMissingEnv = process.env[missingEnvName]; + const out: string[] = []; + const cwd = process.cwd(); + try { + delete process.env[missingEnvName]; + writeInstallableRepo(repo); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + recoverable: { + name: "Recoverable", + description: "Only a missing environment variable.", + command: "recoverable-mcp", + env: { TOKEN: `$env:${missingEnvName}` }, + }, + }, + }), + ); + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); + + await runCli(["install", repo, "github", "--json"], { + env: { + ...process.env, + CAPLETS_CONFIG: configPath, + CAPLETS_PROJECT_CONFIG: projectConfigPath, + CAPLETS_DISABLE_CATALOG_INDEXING: "1", + }, + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toMatchObject({ + entries: [ + expect.objectContaining({ + id: "github", + vaultSetup: expect.objectContaining({ status: "ready" }), + }), + ], + }); + } finally { + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); it("records successful installs before a later install fails", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-install-partial-lockfile-")); diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index b74d3167..dc9e48ff 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -9,7 +9,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { capletJsonSchema, loadCapletFiles, @@ -1759,6 +1759,247 @@ describe("config", () => { rmSync(dir, { recursive: true, force: true }); } }); + it("returns structured Vault quarantine outcomes with source-local safe recovery facts", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-structured-outcomes-")); + try { + const userRoot = join(dir, "user"); + const projectRoot = join(dir, "project"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(projectRoot, ".caplets", "config.json"); + const globalFilePath = join(userRoot, "global-ungranted.md"); + const projectFilePath = join(projectRoot, ".caplets", "project-invalid.md"); + mkdirSync(userRoot, { recursive: true }); + mkdirSync(dirname(projectConfigPath), { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + missing: { + name: "Missing", + description: "Uses a missing Vault key.", + command: "missing-mcp", + env: { TOKEN: "$vault:GLOBAL_MISSING" }, + }, + healthy: { + name: "Healthy", + description: "Has no unresolved Vault references.", + command: "healthy-mcp", + env: { TOKEN: "$vault:AVAILABLE_SECRET" }, + }, + }, + }), + ); + writeFileSync( + globalFilePath, + [ + "---", + "name: Global ungranted", + "description: Uses an ungranted Vault key.", + "mcpServer:", + " command: global-ungranted-mcp", + " env:", + " TOKEN: $vault:GLOBAL_UNGRANTED", + "---", + "# Global ungranted", + ].join("\n"), + ); + writeFileSync( + projectConfigPath, + JSON.stringify({ + mcpServers: { + unavailable: { + name: "Unavailable", + description: "Uses an unavailable Vault key.", + command: "unavailable-mcp", + env: { TOKEN: "$vault:PROJECT_UNAVAILABLE" }, + }, + }, + }), + ); + writeFileSync( + projectFilePath, + [ + "---", + "name: Project invalid", + "description: Uses an invalid Vault key source.", + "mcpServer:", + " command: project-invalid-mcp", + " env:", + " TOKEN: $vault:invalid_key", + "---", + "# Project invalid", + ].join("\n"), + ); + + const { config, sources, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + { + vaultResolver: (reference) => { + if (reference.referenceName === "AVAILABLE_SECRET") { + return { storedKey: reference.referenceName, value: "resolved_vault_secret" }; + } + if (reference.referenceName === "GLOBAL_MISSING") { + return { + reason: "missing" as const, + storedKey: "GLOBAL_MISSING_STORED", + referenceName: reference.referenceName, + capletId: reference.capletId, + origin: reference.origin, + }; + } + if (reference.referenceName === "GLOBAL_UNGRANTED") { + return { + reason: "ungranted" as const, + referenceName: reference.referenceName, + capletId: reference.capletId, + origin: reference.origin, + }; + } + return { + reason: "unavailable" as const, + referenceName: reference.referenceName, + capletId: reference.capletId, + origin: reference.origin, + }; + }, + }, + ); + + const outcomes = warnings.filter((warning) => warning.type === "vault-quarantine"); + expect(outcomes).toEqual([ + { + type: "vault-quarantine", + kind: "global-config", + path: userConfigPath, + message: + "Caplet missing references missing Vault key GLOBAL_MISSING_STORED at mcpServers.missing.env.TOKEN; run `caplets vault set GLOBAL_MISSING_STORED`, then reload Caplets; skipping Caplet missing.", + recoverable: true, + capletId: "missing", + referencePath: "mcpServers.missing.env.TOKEN", + referenceName: "GLOBAL_MISSING", + storedKey: "GLOBAL_MISSING_STORED", + effectiveKey: "GLOBAL_MISSING_STORED", + reason: "missing", + target: "global", + }, + { + type: "vault-quarantine", + kind: "global-file", + path: globalFilePath, + message: + "Caplet global-ungranted references ungranted Vault key GLOBAL_UNGRANTED at mcpServers.global-ungranted.env.TOKEN; run `caplets vault access grant GLOBAL_UNGRANTED global-ungranted` after setting the value, then reload Caplets; skipping Caplet global-ungranted.", + recoverable: true, + capletId: "global-ungranted", + referencePath: "mcpServers.global-ungranted.env.TOKEN", + referenceName: "GLOBAL_UNGRANTED", + effectiveKey: "GLOBAL_UNGRANTED", + reason: "ungranted", + target: "global", + }, + { + type: "vault-quarantine", + kind: "project-config", + path: projectConfigPath, + message: + "Caplet unavailable references unavailable Vault key PROJECT_UNAVAILABLE at mcpServers.unavailable.env.TOKEN; run `caplets vault access grant PROJECT_UNAVAILABLE unavailable` after setting the value, then reload Caplets; skipping Caplet unavailable.", + recoverable: true, + capletId: "unavailable", + referencePath: "mcpServers.unavailable.env.TOKEN", + referenceName: "PROJECT_UNAVAILABLE", + effectiveKey: "PROJECT_UNAVAILABLE", + reason: "unavailable", + target: "global", + }, + { + type: "vault-quarantine", + kind: "project-file", + path: projectFilePath, + message: + "Caplet project-invalid references invalid-key-source Vault key invalid_key at mcpServers.project-invalid.env.TOKEN; run `caplets doctor` for key-source details, then reload Caplets; skipping Caplet project-invalid.", + recoverable: true, + capletId: "project-invalid", + referencePath: "mcpServers.project-invalid.env.TOKEN", + referenceName: "invalid_key", + effectiveKey: "invalid_key", + reason: "invalid-key-source", + target: "global", + }, + ]); + expect(config.mcpServers).toEqual({ + healthy: expect.objectContaining({ + command: "healthy-mcp", + env: { TOKEN: "resolved_vault_secret" }, + }), + }); + expect(sources.healthy).toEqual({ kind: "global-config", path: userConfigPath }); + expect(sources.missing).toBeUndefined(); + expect(JSON.stringify(outcomes)).not.toContain("resolved_vault_secret"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps each unresolved Vault reference as a separate structured outcome", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-multiple-outcomes-")); + try { + const userConfigPath = join(dir, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "Uses two Vault references.", + command: "github-mcp", + env: { + PRIMARY: "$vault:PRIMARY_TOKEN", + SECONDARY: "${vault:SECONDARY_TOKEN}", + }, + }, + }, + }), + ); + + const { config, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + { + vaultResolver: (reference) => ({ + reason: "ungranted", + ...(reference.referenceName === "SECONDARY_TOKEN" + ? { storedKey: "SECONDARY_TOKEN_STORED" } + : {}), + referenceName: reference.referenceName, + capletId: reference.capletId, + origin: reference.origin, + }), + }, + ); + + expect(config.mcpServers.github).toBeUndefined(); + expect(warnings.filter((warning) => warning.type === "vault-quarantine")).toEqual([ + expect.objectContaining({ + capletId: "github", + referencePath: "mcpServers.github.env.PRIMARY", + referenceName: "PRIMARY_TOKEN", + effectiveKey: "PRIMARY_TOKEN", + reason: "ungranted", + }), + expect.objectContaining({ + capletId: "github", + referencePath: "mcpServers.github.env.SECONDARY", + referenceName: "SECONDARY_TOKEN", + storedKey: "SECONDARY_TOKEN_STORED", + effectiveKey: "SECONDARY_TOKEN_STORED", + reason: "ungranted", + }), + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); it("reports remapped missing Vault keys with the stored key repair command", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-vault-remap-warning-")); @@ -1831,6 +2072,11 @@ describe("config", () => { }); expect(warnings[0]?.message).toContain("caplets vault access grant GH_TOKEN github --remote"); + expect(warnings[0]).toMatchObject({ + type: "vault-quarantine", + target: "remote", + effectiveKey: "GH_TOKEN", + }); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/packages/core/test/doctor-cli.test.ts b/packages/core/test/doctor-cli.test.ts index 319dc9b8..b4838146 100644 --- a/packages/core/test/doctor-cli.test.ts +++ b/packages/core/test/doctor-cli.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { dirname, join } from "node:path"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -7,6 +7,8 @@ import { runCli } from "../src/cli"; import { doctorJsonReport } from "../src/cli/doctor"; import { hostedCredentials, tempCloudAuthPath } from "./fixtures/cloud-auth"; import { FileRemoteProfileStore } from "../src/remote/profile-store"; +import * as configModule from "../src/config"; +import type { VaultQuarantineOutcome } from "../src/config"; describe("caplets doctor", () => { it("shows sectioned local diagnostics without stale presence wording", async () => { @@ -500,6 +502,210 @@ describe("caplets doctor", () => { rmSync(dir, { recursive: true, force: true }); } }); + it("maps structured Vault outcomes into Doctor JSON and Markdown without warning parsing", async () => { + const configPath = "/tmp/caplets-doctor-structured-config.json"; + const warnings: VaultQuarantineOutcome[] = [ + { + type: "vault-quarantine", + kind: "project-file" as const, + path: "/tmp/caplets-project/.caplets/github/CAPLET.md", + message: "Human wording and punctuation do not encode Vault facts", + recoverable: true as const, + capletId: "github", + referencePath: "mcpServers.github.env.GH_TOKEN", + referenceName: "GH_TOKEN", + storedKey: "GH_TOKEN_PERSONAL", + effectiveKey: "GH_TOKEN_PERSONAL", + reason: "ungranted" as const, + target: "remote" as const, + }, + { + type: "vault-quarantine", + kind: "global-config" as const, + path: configPath, + message: "No regular-expression recovery data appears here", + recoverable: true as const, + capletId: "missing", + referencePath: "mcpServers.missing.env.TOKEN", + referenceName: "MISSING_TOKEN", + effectiveKey: "MISSING_TOKEN", + reason: "missing" as const, + target: "global" as const, + }, + { + type: "vault-quarantine", + kind: "project-config" as const, + path: "/tmp/caplets-project/.caplets/config.json", + message: "The value is intentionally omitted from this outcome", + recoverable: true as const, + capletId: "unavailable", + referencePath: "mcpServers.unavailable.env.TOKEN", + referenceName: "UNAVAILABLE_TOKEN", + effectiveKey: "UNAVAILABLE_TOKEN", + reason: "unavailable" as const, + target: "global" as const, + }, + { + type: "vault-quarantine", + kind: "global-file" as const, + path: "/tmp/caplets-invalid.md", + message: "The key-source wording is irrelevant to this diagnostic", + recoverable: true as const, + capletId: "invalid", + referencePath: "mcpServers.invalid.env.TOKEN", + referenceName: "invalid_key", + effectiveKey: "invalid_key", + reason: "invalid-key-source" as const, + target: "global" as const, + }, + ]; + const loader = vi.spyOn(configModule, "loadLocalOverlayConfigWithSources").mockReturnValue({ + config: configModule.parseConfig({}), + sources: {}, + shadows: {}, + sourceFound: true, + warnings, + }); + try { + const report = await doctorJsonReport({ + env: { + CAPLETS_CONFIG: configPath, + CAPLETS_PROJECT_CONFIG: "/tmp/caplets-project/.caplets/config.json", + }, + }); + + expect(report.vault).toEqual({ + ok: false, + issues: [ + { + capletId: "github", + reason: "ungranted", + key: "GH_TOKEN_PERSONAL", + configPath: "/tmp/caplets-project/.caplets/github/CAPLET.md", + referencePath: "mcpServers.github.env.GH_TOKEN", + target: "remote", + recoveryCommand: + "caplets vault access grant GH_TOKEN_PERSONAL github --remote --as GH_TOKEN", + }, + { + capletId: "missing", + reason: "missing", + key: "MISSING_TOKEN", + configPath, + referencePath: "mcpServers.missing.env.TOKEN", + target: "global", + recoveryCommand: "caplets vault set MISSING_TOKEN", + }, + { + capletId: "unavailable", + reason: "unavailable", + key: "UNAVAILABLE_TOKEN", + configPath: "/tmp/caplets-project/.caplets/config.json", + referencePath: "mcpServers.unavailable.env.TOKEN", + target: "global", + recoveryCommand: "caplets vault access grant UNAVAILABLE_TOKEN unavailable", + }, + { + capletId: "invalid", + reason: "invalid-key-source", + key: "invalid_key", + configPath: "/tmp/caplets-invalid.md", + referencePath: "mcpServers.invalid.env.TOKEN", + target: "global", + recoveryCommand: "caplets doctor", + }, + ], + }); + expect(JSON.stringify(report.vault)).not.toContain("secret"); + } finally { + loader.mockRestore(); + } + }); + + it("reports Vault loader failure without fabricating Doctor issues", async () => { + const loader = vi + .spyOn(configModule, "loadLocalOverlayConfigWithSources") + .mockImplementation(() => { + throw new Error("Vault overlay loader failed"); + }); + try { + await expect(doctorJsonReport({ env: {} })).resolves.toMatchObject({ + vault: { + ok: false, + issues: [], + message: "Vault overlay loader failed", + }, + }); + } finally { + loader.mockRestore(); + } + }); + + it("reports nonrecoverable best-effort overlay warnings as Vault loader failures", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-doctor-vault-loader-failure-")); + const configPath = join(dir, "config.json"); + try { + writeFileSync(configPath, "{ malformed JSON"); + + await expect( + doctorJsonReport({ + env: { + CAPLETS_CONFIG: configPath, + CAPLETS_PROJECT_CONFIG: join(dir, "project", ".caplets", "config.json"), + }, + }), + ).resolves.toMatchObject({ + vault: { + ok: false, + issues: [], + message: expect.stringContaining("not valid JSON"), + }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps Vault diagnostics OK for recoverable non-Vault overlay warnings", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-doctor-vault-recoverable-warning-")); + const configPath = join(dir, "config.json"); + const missingEnvName = "CAPLETS_U4_DOCTOR_RECOVERABLE_WARNING"; + const originalMissingEnv = process.env[missingEnvName]; + try { + delete process.env[missingEnvName]; + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + recoverable: { + name: "Recoverable", + description: "Only a missing environment variable.", + command: "recoverable-mcp", + env: { TOKEN: `$env:${missingEnvName}` }, + }, + }, + }), + ); + + await expect( + doctorJsonReport({ + env: { + CAPLETS_CONFIG: configPath, + CAPLETS_PROJECT_CONFIG: join(dir, "project", ".caplets", "config.json"), + }, + }), + ).resolves.toMatchObject({ + vault: { ok: true, issues: [] }, + }); + } finally { + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + rmSync(dir, { recursive: true, force: true }); + } + }); it("reports project-bound Caplets as missing session context in exposure diagnostics", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-doctor-project-binding-")); @@ -545,6 +751,51 @@ describe("caplets doctor", () => { rmSync(dir, { recursive: true, force: true }); } }); + it("waits for resolved callable projection entries and excludes failed discovery", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-doctor-callable-projection-")); + const configPath = join(dir, "config.json"); + try { + writeFileSync( + configPath, + JSON.stringify({ + options: { exposureDiscoveryTimeoutMs: 100 }, + mcpServers: { + github: { + name: "GitHub", + description: "GitHub repo operations.", + command: "node", + exposure: "code_mode", + }, + }, + openapiEndpoints: { + unavailable: { + name: "Unavailable API", + description: "An unavailable OpenAPI service.", + specUrl: "http://127.0.0.1:1/openapi.json", + auth: { type: "none" }, + exposure: "code_mode", + }, + }, + }), + ); + + const report = await doctorJsonReport({ + env: { + CAPLETS_CONFIG: configPath, + CAPLETS_PROJECT_CONFIG: join(dir, "project", ".caplets", "config.json"), + }, + }); + + expect(report.exposure).toMatchObject({ + callableNativeToolCount: 1, + }); + expect(report.codeMode).toMatchObject({ + callableIndex: { ok: true, callableCount: 1 }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); it("honors DoctorOptions.cwd when checking project Vault references", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-doctor-cwd-")); From 14c9128744065744b8d808b492b9f07441b12872 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 10 Jul 2026 06:58:32 -0400 Subject: [PATCH 5/9] refactor(core): centralize Current Host administration Route dashboard and Operator bearer administration through one typed operations boundary with exact role enforcement, shared redaction, and actor-attributed outcomes. Keep Raw Vault Reveal dashboard-only with no-store responses and ephemeral browser state. --- .changeset/calm-media-seam.md | 2 + .../src/components/DashboardApp.test.tsx | 238 ++++ .../dashboard/src/components/DashboardApp.tsx | 291 ++-- .../src/lib/ephemeral-reveal.test.ts | 61 + apps/dashboard/src/lib/ephemeral-reveal.ts | 36 + apps/dashboard/vitest.config.ts | 10 + docs/architecture.md | 8 + .../src/current-host/catalog-operations.ts | 220 +++ .../{dashboard => current-host}/catalog.ts | 196 +-- .../src/current-host/client-operations.ts | 297 ++++ packages/core/src/current-host/operations.ts | 553 ++++++++ .../core/src/current-host/vault-operations.ts | 302 ++++ packages/core/src/remote-control/dispatch.ts | 268 ++-- packages/core/src/remote-control/types.ts | 6 +- packages/core/src/serve/http.ts | 1245 ++++++++++------- .../test/current-host-administration.test.ts | 609 ++++++++ packages/core/test/dashboard-api.test.ts | 22 +- packages/core/test/dashboard-catalog.test.ts | 51 + packages/core/test/dashboard-session.test.ts | 58 +- packages/core/test/dashboard-vault.test.ts | 83 +- .../core/test/remote-control-dispatch.test.ts | 46 +- packages/core/test/serve-http.test.ts | 335 ++++- 22 files changed, 3941 insertions(+), 996 deletions(-) create mode 100644 apps/dashboard/src/components/DashboardApp.test.tsx create mode 100644 apps/dashboard/src/lib/ephemeral-reveal.test.ts create mode 100644 apps/dashboard/src/lib/ephemeral-reveal.ts create mode 100644 apps/dashboard/vitest.config.ts create mode 100644 packages/core/src/current-host/catalog-operations.ts rename packages/core/src/{dashboard => current-host}/catalog.ts (63%) create mode 100644 packages/core/src/current-host/client-operations.ts create mode 100644 packages/core/src/current-host/operations.ts create mode 100644 packages/core/src/current-host/vault-operations.ts create mode 100644 packages/core/test/current-host-administration.test.ts diff --git a/.changeset/calm-media-seam.md b/.changeset/calm-media-seam.md index 1445a9cb..e57c7087 100644 --- a/.changeset/calm-media-seam.md +++ b/.changeset/calm-media-seam.md @@ -10,3 +10,5 @@ GraphQL operation results now share the Media pipeline with a 1 MiB inline thres Replace `handleServerTool`'s positional manager arguments with a named backend runtime. External `@caplets/core` callers now construct that runtime with `createBackendOperationRuntime`; common backend operations dispatch through its `operations` Interface, while MCP-only resource, prompt, and completion methods remain on `runtime.mcp`. Make exposure projection the generation-bound callable-surface authority. MCP, Attach, and native adapters now render registration facts and Code Mode identities from the same projection, reject stale callbacks across reloads, discard out-of-order discovery, and keep hidden or unresolved Caplets out of declarations and execution allowlists. + +Concentrate Current Host administration behind one typed operations Interface shared by dashboard and Operator bearer adapters. Operator activity now records the real acting Client, exact Access and Operator route roles are enforced, both Client roles can revoke only their own credential, and self-revocation or demotion ends the acting dashboard session. Raw Vault Reveal remains dashboard-only and expires from browser memory. diff --git a/apps/dashboard/src/components/DashboardApp.test.tsx b/apps/dashboard/src/components/DashboardApp.test.tsx new file mode 100644 index 00000000..bbf1638c --- /dev/null +++ b/apps/dashboard/src/components/DashboardApp.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { dashboardApi, setDashboardSession, toast } = vi.hoisted(() => ({ + dashboardApi: vi.fn(), + setDashboardSession: vi.fn(), + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("@/lib/api", () => ({ + dashboardApi, + isDashboardUnauthorized: () => false, + setDashboardSession, +})); + +vi.mock("@/components/ui/sonner", () => ({ Toaster: () => null })); +vi.mock("sonner", () => ({ toast })); + +import { DashboardApp } from "./DashboardApp"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +const vaultKey = "TEST_SECRET"; +const session = { + sessionId: "session-id", + operatorClientId: "operator-id", + csrfToken: "csrf-token", +}; + +let root: Root | undefined; +let container: HTMLDivElement | undefined; +let revealResponses: Array | { value: string }>; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + return { promise, resolve }; +} + +function responseFor(path: string) { + if (path === "session") return { authenticated: true, session }; + if (path === "vault") return { values: [{ key: vaultKey, valueBytes: 12 }] }; + if (path === "vault/reveal") { + const response = revealResponses.shift(); + if (!response) throw new Error("Unexpected vault reveal request."); + return "promise" in response ? response.promise : response; + } + return {}; +} + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function waitFor(read: () => T | undefined): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + const value = read(); + if (value !== undefined) return value; + await flush(); + } + throw new Error("Timed out waiting for dashboard state."); +} + +function button(label: string): HTMLButtonElement { + const result = Array.from(document.querySelectorAll("button")).find( + (candidate) => + candidate.getAttribute("aria-label") === label || candidate.textContent?.trim() === label, + ); + if (!result) throw new Error(`Could not find button: ${label}`); + return result; +} + +async function mountVault() { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + await waitFor( + () => + document.querySelector( + `button[aria-label="Reveal vault value ${vaultKey}"]`, + ) ?? undefined, + ); +} + +async function openRevealConfirmation() { + await act(async () => { + button(`Reveal vault value ${vaultKey}`).click(); + }); + const phrase = await waitFor( + () => + document.querySelector( + `input[aria-label="Type reveal ${vaultKey} to confirm"]`, + ) ?? undefined, + ); + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(phrase, `reveal ${vaultKey}`); + phrase.dispatchEvent(new Event("input", { bubbles: true })); + }); + await waitFor(() => (button(`Reveal for 30s`).disabled ? undefined : button(`Reveal for 30s`))); +} + +async function confirmReveal() { + await act(async () => { + button(`Reveal for 30s`).click(); + }); +} + +async function reveal(value: string) { + revealResponses.push({ value }); + await openRevealConfirmation(); + await confirmReveal(); + await waitFor( + () => document.querySelector(`[aria-label="Hide revealed value"]`) ?? undefined, + ); +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + revealResponses = []; + dashboardApi.mockImplementation((path: string) => Promise.resolve(responseFor(path))); + dashboardApi.mockClear(); + setDashboardSession.mockClear(); + toast.error.mockClear(); + toast.success.mockClear(); + window.history.replaceState({}, "", "/dashboard/vault"); + window.matchMedia = vi.fn().mockReturnValue({ + addEventListener: vi.fn(), + addListener: vi.fn(), + matches: false, + media: "", + removeEventListener: vi.fn(), + removeListener: vi.fn(), + }); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + container?.remove(); + root = undefined; + container = undefined; + vi.restoreAllMocks(); +}); + +describe("Vault reveal races", () => { + it("dismisses a parent-owned reveal confirmation when Vault unmounts", async () => { + await mountVault(); + await openRevealConfirmation(); + + await act(async () => { + window.history.replaceState({}, "", "/dashboard"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + await flush(); + + expect(document.body.textContent).not.toContain("Reveal secret value?"); + expect(dashboardApi.mock.calls.filter(([path]) => path === "vault/reveal")).toHaveLength(0); + }); + + it("does not refresh or toast when Hide invalidates a pending reveal", async () => { + await mountVault(); + await reveal("visible secret"); + toast.success.mockClear(); + + const stale = deferred<{ value: string }>(); + revealResponses.push(stale); + await openRevealConfirmation(); + await confirmReveal(); + await waitFor(() => + dashboardApi.mock.calls.some(([path]) => path === "vault/reveal") ? true : undefined, + ); + dashboardApi.mockClear(); + + await act(async () => { + button("Hide revealed value").click(); + }); + await act(async () => { + stale.resolve({ value: "stale secret" }); + }); + await flush(); + + expect(dashboardApi).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("does not refresh or toast when a newer reveal invalidates an older response", async () => { + await mountVault(); + await reveal("visible secret"); + toast.success.mockClear(); + dashboardApi.mockClear(); + + const stale = deferred<{ value: string }>(); + const current = deferred<{ value: string }>(); + revealResponses.push(stale, current); + await openRevealConfirmation(); + await confirmReveal(); + await openRevealConfirmation(); + await confirmReveal(); + await waitFor(() => + dashboardApi.mock.calls.filter(([path]) => path === "vault/reveal").length === 2 + ? true + : undefined, + ); + dashboardApi.mockClear(); + + await act(async () => { + stale.resolve({ value: "stale secret" }); + }); + await flush(); + + expect(dashboardApi).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + + await act(async () => { + current.resolve({ value: "current secret" }); + }); + await flush(); + }); +}); diff --git a/apps/dashboard/src/components/DashboardApp.tsx b/apps/dashboard/src/components/DashboardApp.tsx index a048248b..b7ac699a 100644 --- a/apps/dashboard/src/components/DashboardApp.tsx +++ b/apps/dashboard/src/components/DashboardApp.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ComponentProps, type ReactNode, @@ -93,8 +94,12 @@ import { setDashboardSession, type DashboardSession, } from "@/lib/api"; +import { EPHEMERAL_REVEAL_TTL_MS, createEphemeralRevealExpiry } from "@/lib/ephemeral-reveal"; import { dashboardBasePath, dashboardPath } from "@/lib/paths"; +const REVEAL_DURATION_SECONDS = EPHEMERAL_REVEAL_TTL_MS / 1_000; +const ACTION_DISCARDED = Symbol("dashboard-action-discarded"); + type RouteKey = | "overview" | "access" @@ -203,6 +208,7 @@ type ConfirmationRequest = ConfirmationOptions & { const ConfirmContext = createContext< ((options: ConfirmationOptions) => Promise) | null >(null); +const ConfirmDismissContext = createContext<(() => void) | null>(null); function useConfirm() { const confirm = useContext(ConfirmContext); @@ -210,6 +216,13 @@ function useConfirm() { return confirm; } +function useDismissConfirmation() { + const dismissConfirmation = useContext(ConfirmDismissContext); + if (!dismissConfirmation) + throw new Error("useDismissConfirmation must be used inside DashboardApp."); + return dismissConfirmation; +} + function useActionConfirm() { const confirm = useConfirm(); return { @@ -237,23 +250,32 @@ export function DashboardApp({ initialRoute = "overview" }: { initialRoute?: Rou const [authCommand, setAuthCommand] = useState(""); const [authMessage, setAuthMessage] = useState("Restoring dashboard session…"); const [confirmation, setConfirmation] = useState(); - - const confirm = useCallback((options: ConfirmationOptions) => { - const finalFocus = - document.activeElement instanceof HTMLElement ? document.activeElement : null; - return new Promise((resolve) => - setConfirmation({ ...options, finalFocus, resolve }), - ); + const confirmationRef = useRef(undefined); + + const resolveConfirmation = useCallback((confirmed: ConfirmationResult) => { + const activeConfirmation = confirmationRef.current; + if (!activeConfirmation) return; + confirmationRef.current = undefined; + activeConfirmation.resolve(confirmed); + setConfirmation(undefined); }, []); - const resolveConfirmation = useCallback( - (confirmed: ConfirmationResult) => { - confirmation?.resolve(confirmed); - setConfirmation(undefined); + const confirm = useCallback( + (options: ConfirmationOptions) => { + const finalFocus = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + return new Promise((resolve) => { + resolveConfirmation(false); + const request = { ...options, finalFocus, resolve }; + confirmationRef.current = request; + setConfirmation(request); + }); }, - [confirmation], + [resolveConfirmation], ); + const dismissConfirmation = useCallback(() => resolveConfirmation(false), [resolveConfirmation]); + useEffect(() => { let cancelled = false; void restoreDashboardSession(0, () => cancelled); @@ -408,7 +430,8 @@ export function DashboardApp({ initialRoute = "overview" }: { initialRoute?: Rou async function action(label: string, callback: () => Promise) { try { - await callback(); + const result = await callback(); + if (result === ACTION_DISCARDED) return; const refreshed = await refresh(); if (refreshed) toast.success(label); } catch (error) { @@ -506,111 +529,113 @@ export function DashboardApp({ initialRoute = "overview" }: { initialRoute?: Rou const isDevelopmentSession = session.operatorClientId === "development_unauthenticated"; content = ( - - - - Skip to dashboard content - - - -
- -
-
Caplets
-
Operator console
+ + + + + Skip to dashboard content + + + +
+ +
+
Caplets
+
Operator console
+
-
- - - - Dashboard - - - - - - -
- - {isDevelopmentSession ? ( -
-
No-auth mode
-
- Operator checks are bypassed locally. + + + + Dashboard + + + + + + +
+ + {isDevelopmentSession ? ( +
+
No-auth mode
+
+ Operator checks are bypassed locally. +
+ ) : ( +
+ void logout()} + > + + +
+ )} +
+
+ + +
+ + + + +
+
+ {data.summary?.host?.baseUrl ?? "Current Host"}
- ) : ( -
- void logout()} - > - - -
- )} -
- - - -
- - - - -
-
- {data.summary?.host?.baseUrl ?? "Current Host"}
+ action("Dashboard refreshed", refresh)} + > + + +
+
+
- action("Dashboard refreshed", refresh)} - > - - -
-
- -
-
- - - - + + + + + + ); } @@ -2347,20 +2372,35 @@ function VaultPage({ action: (label: string, callback: () => Promise) => Promise; }) { const confirm = useConfirm(); + const dismissConfirmation = useDismissConfirmation(); const { confirmTyped } = useActionConfirm(); const [key, setKey] = useState(""); const [value, setValue] = useState(""); const [revealed, setRevealed] = useState<{ key: string; value: string; expiresAt: number }>(); const [now, setNow] = useState(Date.now()); const values = data.vault?.values ?? []; + const isMounted = useRef(true); + const revealGeneration = useRef(0); + const expiry = useMemo(() => createEphemeralRevealExpiry(() => setRevealed(undefined)), []); + function clearRevealed() { + revealGeneration.current += 1; + expiry.cancel(); + setRevealed(undefined); + } + useEffect(() => { + isMounted.current = true; + return () => { + dismissConfirmation(); + isMounted.current = false; + revealGeneration.current += 1; + expiry.cancel(); + }; + }, [dismissConfirmation, expiry]); useEffect(() => { if (!revealed) return; const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, [revealed]); - useEffect(() => { - if (revealed && now >= revealed.expiresAt) setRevealed(undefined); - }, [now, revealed]); async function setVaultValue() { if (!key || !value) return; const replacing = values.some((entry) => String(entry.key) === key); @@ -2437,7 +2477,7 @@ function VaultPage({ setRevealed(undefined)} + onHide={clearRevealed} /> ) : null} @@ -2477,12 +2517,13 @@ function VaultPage({ onClick={async () => { const confirmation = await confirm({ title: "Reveal secret value?", - description: `${rawKey} will be visible in a dedicated panel for 30 seconds in this browser session only.`, + description: `${rawKey} will be visible in a dedicated panel for ${REVEAL_DURATION_SECONDS} seconds in this browser session only.`, expectedPhrase: `reveal ${rawKey}`, - confirmLabel: "Reveal for 30s", + confirmLabel: `Reveal for ${REVEAL_DURATION_SECONDS}s`, destructive: true, }); - if (confirmation !== `reveal ${rawKey}`) return; + if (!isMounted.current || confirmation !== `reveal ${rawKey}`) return; + const requestGeneration = ++revealGeneration.current; await action("Vault value revealed", async () => { const revealed = await dashboardApi<{ value: string }>("vault/reveal", { method: "POST", @@ -2491,11 +2532,19 @@ function VaultPage({ confirmation, }), }); + if ( + !isMounted.current || + requestGeneration !== revealGeneration.current + ) + return ACTION_DISCARDED; + const revealedAt = Date.now(); + setNow(revealedAt); setRevealed({ key: rawKey, value: revealed.value, - expiresAt: Date.now() + 30_000, + expiresAt: revealedAt + EPHEMERAL_REVEAL_TTL_MS, }); + expiry.replace(); }); }} > diff --git a/apps/dashboard/src/lib/ephemeral-reveal.test.ts b/apps/dashboard/src/lib/ephemeral-reveal.test.ts new file mode 100644 index 00000000..9d6c9ad0 --- /dev/null +++ b/apps/dashboard/src/lib/ephemeral-reveal.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { EPHEMERAL_REVEAL_TTL_MS, createEphemeralRevealExpiry } from "./ephemeral-reveal"; + +describe("createEphemeralRevealExpiry", () => { + it("expires once after the configured TTL", () => { + vi.useFakeTimers(); + try { + const onExpire = vi.fn(); + const expiry = createEphemeralRevealExpiry(onExpire); + + expiry.replace(); + vi.advanceTimersByTime(EPHEMERAL_REVEAL_TTL_MS - 1); + expect(onExpire).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(onExpire).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(EPHEMERAL_REVEAL_TTL_MS); + expect(onExpire).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("replaces a pending expiry without letting the older timer expire the replacement", () => { + vi.useFakeTimers(); + try { + const onExpire = vi.fn(); + const expiry = createEphemeralRevealExpiry(onExpire); + + expiry.replace(); + vi.advanceTimersByTime(10_000); + expiry.replace(); + + vi.advanceTimersByTime(20_000); + expect(onExpire).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(10_000); + expect(onExpire).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels a pending expiry idempotently", () => { + vi.useFakeTimers(); + try { + const onExpire = vi.fn(); + const expiry = createEphemeralRevealExpiry(onExpire); + + expiry.replace(); + expiry.cancel(); + expiry.cancel(); + vi.advanceTimersByTime(EPHEMERAL_REVEAL_TTL_MS); + + expect(onExpire).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/dashboard/src/lib/ephemeral-reveal.ts b/apps/dashboard/src/lib/ephemeral-reveal.ts new file mode 100644 index 00000000..22e4a997 --- /dev/null +++ b/apps/dashboard/src/lib/ephemeral-reveal.ts @@ -0,0 +1,36 @@ +export const EPHEMERAL_REVEAL_TTL_MS = 30_000; + +export type EphemeralRevealScheduler = { + setTimeout(callback: () => void, delayMs: number): number; + clearTimeout(handle: number): void; +}; + +export type EphemeralRevealExpiry = { + replace(): void; + cancel(): void; +}; + +export function createEphemeralRevealExpiry( + onExpire: () => void, + scheduler: EphemeralRevealScheduler = globalThis, + ttlMs = EPHEMERAL_REVEAL_TTL_MS, +): EphemeralRevealExpiry { + let timeout: number | undefined; + + const cancel = () => { + if (timeout === undefined) return; + scheduler.clearTimeout(timeout); + timeout = undefined; + }; + + return { + replace() { + cancel(); + timeout = scheduler.setTimeout(() => { + timeout = undefined; + onExpire(); + }, ttlMs); + }, + cancel, + }; +} diff --git a/apps/dashboard/vitest.config.ts b/apps/dashboard/vitest.config.ts new file mode 100644 index 00000000..f2d7b49a --- /dev/null +++ b/apps/dashboard/vitest.config.ts @@ -0,0 +1,10 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, +}); diff --git a/docs/architecture.md b/docs/architecture.md index f8697c71..d82cbaba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,6 +56,14 @@ The HTTP server in `packages/core/src/serve/http.ts` exposes versioned MCP, atta `/v1/attach` is the Caplets runtime attach API. Attached clients read `/v1/attach/manifest`, subscribe to `/v1/attach/events`, and invoke revision-scoped exports through `/v1/attach/invoke` before merging remote projections with local/project overlays. +### Current Host Administration + +`packages/core/src/current-host/operations.ts` is the Current Host administration Module. Its typed Interface accepts a trusted host-scoped Operator principal plus a semantic query or command, then owns safe read models, catalog and Caplet administration, Pending Remote Login and Remote Client mutations, safe Vault administration, Operator activity, redaction, and actor-specific `sessionEnded` outcomes. + +The human dashboard and `/v1/admin` Operator bearer routes are separate Adapters over that Interface. The dashboard retains cookie, CSRF, session, and browser presentation ceremony; the bearer Adapter retains the existing `RemoteCliRequest` selection and safe response envelope. Access Clients remain limited to MCP, Attach, and Project Binding routes. Both Access and Operator Clients may revoke only their own credential through the role-neutral self-revoke route. + +Raw Vault Reveal is not a shared operation. It remains a dashboard-only human confirmation path with `no-store` responses and an ephemeral browser timer; generic bearer administration rejects it. + ### Caplets Daemon `packages/core/src/daemon/` owns the default per-user daemon lifecycle. `caplets daemon install` persists HTTP `caplets serve` configuration, explicit service environment variables, optional shell inheritance intent, user-only log paths, and native service descriptors under the `daemon/default` identity. Runtime lifecycle commands (`start`, `restart`, `stop`, `status`, `logs`, and `uninstall`) read that installed service state instead of accepting serve flags. diff --git a/packages/core/src/current-host/catalog-operations.ts b/packages/core/src/current-host/catalog-operations.ts new file mode 100644 index 00000000..b9ed5890 --- /dev/null +++ b/packages/core/src/current-host/catalog-operations.ts @@ -0,0 +1,220 @@ +import { defaultCapletsLockfilePath, resolveCapletsRoot } from "../config"; +import { SERVER_ID_PATTERN } from "../config/validation"; +import { + indexInstalledCapletsFromLockfile, + installCaplets, + restoreCapletsFromLockfile, + updateCapletsFromLockfile, +} from "./../cli/install"; +import { CapletsError } from "../errors"; +import { + currentHostCatalogDetail, + currentHostCatalogInstallSource, + currentHostCatalogSearch, + currentHostCatalogUpdateReadiness, + currentHostInstalledCaplets, + currentHostSetupActionsForInstalled, +} from "./catalog"; +import type { + CurrentHostControlContext, + CurrentHostOperation, + CurrentHostOperationOutcome, + CurrentHostOperatorPrincipal, + CurrentHostOperationsDependencies, +} from "./operations"; + +type CapletsListOperation = Extract; +type CatalogSearchOperation = Extract; +type CatalogDetailOperation = Extract; +type CatalogUpdatesOperation = Extract; +type CatalogInstallOperation = Extract; +type CatalogUpdateOperation = Extract; +type CapletsListOutcome = Extract; +type CatalogSearchOutcome = Extract; +type CatalogDetailOutcome = Extract; +type CatalogUpdatesOutcome = Extract; +type CatalogInstallOutcome = Extract; +type CatalogUpdateOutcome = Extract; + +/** Current Host catalog administration implementation, kept behind the facade. */ +export function createCurrentHostCatalogOperations( + dependencies: CurrentHostOperationsDependencies, +) { + return { + capletsList: (_operation: CapletsListOperation): CapletsListOutcome => ({ + kind: "caplets_list", + caplets: currentHostInstalledCaplets(dependencies.engine.enabledServers(), { + globalLockfilePath: dependencies.control?.globalLockfilePath, + }), + }), + search: (operation: CatalogSearchOperation): CatalogSearchOutcome => ({ + kind: "catalog_search", + ...currentHostCatalogSearch(operation), + }), + detail: (operation: CatalogDetailOperation): CatalogDetailOutcome => ({ + kind: "catalog_detail", + ...currentHostCatalogDetail(operation), + }), + updates: (_operation: CatalogUpdatesOperation): CatalogUpdatesOutcome => ({ + kind: "catalog_updates", + ...currentHostCatalogUpdateReadiness({ + context: { globalLockfilePath: dependencies.control?.globalLockfilePath }, + }), + }), + install: ( + principal: CurrentHostOperatorPrincipal, + operation: CatalogInstallOperation, + ): Promise => catalogInstallOutcome(dependencies, principal, operation), + update: ( + principal: CurrentHostOperatorPrincipal, + operation: CatalogUpdateOperation, + ): Promise => catalogUpdateOutcome(dependencies, principal, operation), + }; +} + +async function catalogInstallOutcome( + dependencies: CurrentHostOperationsDependencies, + principal: CurrentHostOperatorPrincipal, + operation: CatalogInstallOperation, +): Promise { + const capletIds = optionalCapletIds(operation.capletIds); + if (operation.source !== undefined && operation.repo !== undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "Catalog install accepts either a source or repository, not both.", + ); + } + try { + const control = requireControlContext(dependencies.control, "Catalog actions"); + const target = globalCatalogTarget(control); + const repo = + operation.repo ?? + (operation.source === undefined + ? undefined + : currentHostCatalogInstallSource(operation.source)); + const installOptions = { + ...target, + ...(capletIds === undefined ? {} : { capletIds }), + ...(operation.force === undefined ? {} : { force: operation.force }), + }; + const installed = repo + ? installCaplets(repo, installOptions).installed + : restoreCapletsFromLockfile(installOptions).installed; + await attachCatalogIndexing(installed, operation.disableCatalogIndexing ?? false); + for (const entry of installed) { + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "catalog_installed", + target: { type: "catalog", id: entry.id }, + metadata: { status: entry.status ?? null, kind: entry.kind }, + }); + } + const catalogSource = operation.source; + const setupActions = + catalogSource === undefined + ? [] + : installed.flatMap((entry) => + currentHostSetupActionsForInstalled(catalogSource, entry.id), + ); + return { kind: "catalog_install", installed, setupActions }; + } catch (error) { + appendFailureActivity(dependencies, principal, "catalog_installed", { + type: "catalog", + id: capletIds?.[0] ?? "current-host", + }); + throw error; + } +} + +async function catalogUpdateOutcome( + dependencies: CurrentHostOperationsDependencies, + principal: CurrentHostOperatorPrincipal, + operation: CatalogUpdateOperation, +): Promise { + const capletIds = optionalCapletIds(operation.capletIds); + try { + const control = requireControlContext(dependencies.control, "Catalog actions"); + const installed = updateCapletsFromLockfile({ + ...globalCatalogTarget(control), + ...(capletIds === undefined ? {} : { capletIds }), + ...(operation.force === undefined ? {} : { force: operation.force }), + ...(operation.allowRiskIncrease === undefined + ? {} + : { allowRiskIncrease: operation.allowRiskIncrease }), + }).installed; + await attachCatalogIndexing(installed, operation.disableCatalogIndexing ?? false); + for (const entry of installed) { + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "catalog_updated", + target: { type: "catalog", id: entry.id }, + metadata: { + status: entry.status ?? null, + riskAcknowledged: operation.allowRiskIncrease ?? false, + }, + }); + } + return { kind: "catalog_update", installed, setupActions: [] }; + } catch (error) { + appendFailureActivity(dependencies, principal, "catalog_updated", { + type: "catalog", + id: capletIds?.[0] ?? "current-host", + }); + throw error; + } +} + +function globalCatalogTarget(control: CurrentHostControlContext): { + destinationRoot: string; + lockfilePath: string; +} { + return { + destinationRoot: control.globalCapletsRoot ?? resolveCapletsRoot(control.configPath), + lockfilePath: control.globalLockfilePath ?? defaultCapletsLockfilePath(), + }; +} + +async function attachCatalogIndexing( + installed: Array<{ + id: string; + lockfile?: string | undefined; + catalogIndexing?: unknown; + }>, + disableCatalogIndexing: boolean, +): Promise { + const indexed = await indexInstalledCapletsFromLockfile(installed, { disableCatalogIndexing }); + for (const entry of installed) entry.catalogIndexing = indexed.get(entry.id); +} + +function requireControlContext( + control: CurrentHostControlContext | undefined, + purpose: string, +): CurrentHostControlContext { + if (control) return control; + throw new CapletsError("SERVER_UNAVAILABLE", `${purpose} require server control context.`); +} + +function optionalCapletIds(value: string[] | undefined): string[] | undefined { + if (value === undefined) return undefined; + for (const capletId of value) requiredCapletId(capletId); + return value; +} + +function requiredCapletId(value: unknown): string { + if (typeof value === "string" && SERVER_ID_PATTERN.test(value)) return value; + throw new CapletsError("REQUEST_INVALID", "Caplet ID is invalid."); +} + +function appendFailureActivity( + dependencies: CurrentHostOperationsDependencies, + principal: CurrentHostOperatorPrincipal, + action: "catalog_installed" | "catalog_updated", + target: { type: "catalog"; id: string }, +): void { + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action, + outcome: "failure", + target, + }); +} diff --git a/packages/core/src/dashboard/catalog.ts b/packages/core/src/current-host/catalog.ts similarity index 63% rename from packages/core/src/dashboard/catalog.ts rename to packages/core/src/current-host/catalog.ts index c5aa5a47..527c726c 100644 --- a/packages/core/src/dashboard/catalog.ts +++ b/packages/core/src/current-host/catalog.ts @@ -21,16 +21,12 @@ import { type CatalogEntry, type CatalogSourceIdentity, } from "../catalog"; -import { - dispatchRemoteCliRequest, - type RemoteControlDispatchContext, -} from "../remote-control/dispatch"; -export type DashboardCatalogContext = { - control?: RemoteControlDispatchContext | undefined; +export type CurrentHostCatalogContext = { + globalLockfilePath?: string | undefined; }; -export type DashboardInstalledCaplet = { +export type CurrentHostInstalledCapletProjection = { id: string; name: string; description: string; @@ -41,10 +37,10 @@ export type DashboardInstalledCaplet = { projectBindingRequired: boolean; source?: string | undefined; updateState: "unknown" | "locked"; - setupActions: DashboardSetupAction[]; + setupActions: CurrentHostSetupAction[]; }; -export type DashboardSetupAction = { +export type CurrentHostSetupAction = { kind: | "auth" | "vault" @@ -56,7 +52,7 @@ export type DashboardSetupAction = { required: boolean; }; -export type DashboardInstalledCatalogCaplet = { +export type CurrentHostInstalledCatalogCaplet = { id: string; source: string; destination: string; @@ -67,15 +63,15 @@ export type DashboardInstalledCatalogCaplet = { catalogIndexing?: unknown; }; -export type DashboardCatalogInstallResult = { - installed: DashboardInstalledCatalogCaplet[]; - setupActions: DashboardSetupAction[]; +export type CurrentHostCatalogInstallResult = { + installed: CurrentHostInstalledCatalogCaplet[]; + setupActions: CurrentHostSetupAction[]; }; -export function dashboardInstalledCaplets( +export function currentHostInstalledCaplets( caplets: CapletConfig[], - context: DashboardCatalogContext, -): DashboardInstalledCaplet[] { + context: CurrentHostCatalogContext, +): CurrentHostInstalledCapletProjection[] { const lockEntries = safeLockEntries(lockfilePath(context)); return caplets .map((caplet) => { @@ -108,7 +104,7 @@ export function dashboardInstalledCaplets( .sort((left, right) => left.id.localeCompare(right.id)); } -export function dashboardCatalogSearch(input: { +export function currentHostCatalogSearch(input: { source: string; query?: string | undefined; limit?: number | undefined; @@ -126,9 +122,9 @@ export function dashboardCatalogSearch(input: { return { entries: filtered.slice(0, boundedLimit(input.limit)) }; } -export function dashboardCatalogDetail(input: { source: string; capletId: string }): { +export function currentHostCatalogDetail(input: { source: string; capletId: string }): { entry: CatalogEntry; - setupActions: DashboardSetupAction[]; + setupActions: CurrentHostSetupAction[]; projectScopedInstallAvailable: false; } { const entry = catalogEntriesFromSource(input.source).find( @@ -143,56 +139,7 @@ export function dashboardCatalogDetail(input: { source: string; capletId: string }; } -export async function dashboardInstallCatalogCaplet(input: { - source: string; - capletId: string; - force?: boolean | undefined; - context: DashboardCatalogContext; -}): Promise { - const response = await dispatchRemoteCliRequest( - { - command: "install", - arguments: { - repo: installSourceFor(input.source), - capletIds: [input.capletId], - disableCatalogIndexing: true, - ...(input.force === undefined ? {} : { force: input.force }), - }, - }, - requiredControlContext(input.context), - ); - if (!response.ok) throw new CapletsError(response.error.code, response.error.message); - return { - installed: installedFromDispatchResult(response.result), - setupActions: setupActionsForInstalled(input.source, input.capletId), - }; -} - -export async function dashboardUpdateCatalogCaplet(input: { - capletId: string; - force?: boolean | undefined; - allowRiskIncrease?: boolean | undefined; - context: DashboardCatalogContext; -}): Promise { - const response = await dispatchRemoteCliRequest( - { - command: "update", - arguments: { - capletIds: [input.capletId], - disableCatalogIndexing: true, - ...(input.force === undefined ? {} : { force: input.force }), - ...(input.allowRiskIncrease === undefined - ? {} - : { allowRiskIncrease: input.allowRiskIncrease }), - }, - }, - requiredControlContext(input.context), - ); - if (!response.ok) throw new CapletsError(response.error.code, response.error.message); - return { installed: installedFromDispatchResult(response.result), setupActions: [] }; -} - -export function dashboardUpdateReadiness(input: { context: DashboardCatalogContext }): { +export function currentHostCatalogUpdateReadiness(input: { context: CurrentHostCatalogContext }): { updates: Array<{ id: string; status: "locked"; risk: unknown }>; } { return { @@ -204,37 +151,54 @@ export function dashboardUpdateReadiness(input: { context: DashboardCatalogConte }; } +export function currentHostSetupActionsForInstalled( + source: string, + capletId: string, +): CurrentHostSetupAction[] { + try { + return setupActionsForEntry(currentHostCatalogDetail({ source, capletId }).entry); + } catch { + return []; + } +} + const officialCatalogRepository = "spiritledsoftware/caplets"; -type ResolvedDashboardCatalogSource = { +export function currentHostCatalogInstallSource(source: string): string { + return source.trim() === "official" ? officialCatalogRepository : source; +} + +type ResolvedCurrentHostCatalogSource = { root: string; source: CatalogSourceIdentity; trustLevel: "official" | "community"; }; +type LockEntry = { id: string; source?: { type?: string; repository?: string }; risk?: unknown }; + function catalogEntriesFromSource(sourceInput: string): CatalogEntry[] { - const resolved = resolveDashboardCatalogSource(sourceInput); - const sourceRoot = join(resolved.root, "caplets"); + const resolvedSource = resolveCurrentHostCatalogSource(sourceInput); + const sourceRoot = join(resolvedSource.root, "caplets"); const files = discoverCapletFiles(sourceRoot); return files .map(({ id, path }) => { const contentMarkdown = readFileSync(path, "utf8"); const frontmatter = readCatalogCapletFrontmatterFromMarkdown(contentMarkdown); - const sourcePath = sourceRelativePath(resolved.root, path); + const sourcePath = sourceRelativePath(resolvedSource.root, path); return createCatalogEntry({ id, name: catalogStringFromFrontmatter(frontmatter.name) ?? id, description: catalogStringFromFrontmatter(frontmatter.description) ?? `Catalog Caplet ${id}.`, - source: resolved.source, + source: resolvedSource.source, sourcePath, - trustLevel: resolved.trustLevel, + trustLevel: resolvedSource.trustLevel, contentMarkdown, icon: catalogIconFromFrontmatter(frontmatter, { id, - source: resolved.source, + source: resolvedSource.source, sourcePath, - trustLevel: resolved.trustLevel, + trustLevel: resolvedSource.trustLevel, }), tags: catalogStringArrayFromFrontmatter(frontmatter.tags), useWhen: catalogStringFromFrontmatter(frontmatter.useWhen), @@ -253,9 +217,8 @@ function catalogEntriesFromSource(sourceInput: string): CatalogEntry[] { .sort((left, right) => left.id.localeCompare(right.id)); } -function resolveDashboardCatalogSource(sourceInput: string): ResolvedDashboardCatalogSource { - const trimmed = sourceInput.trim(); - if (trimmed === "official") { +function resolveCurrentHostCatalogSource(sourceInput: string): ResolvedCurrentHostCatalogSource { + if (sourceInput.trim() === "official") { const source = normalizeCatalogSourceIdentity(officialCatalogRepository); if (!source.eligible) { throw new CapletsError("CONFIG_INVALID", "Official catalog source is invalid."); @@ -274,10 +237,6 @@ function resolveDashboardCatalogSource(sourceInput: string): ResolvedDashboardCa }; } -function installSourceFor(sourceInput: string): string { - return sourceInput.trim() === "official" ? officialCatalogRepository : sourceInput; -} - function officialCatalogRoot(): string { for (const start of [process.cwd(), dirname(fileURLToPath(import.meta.url))]) { const found = findRepoRootWithCaplets(start); @@ -313,15 +272,7 @@ function sourceRelativePath(source: string, path: string): string { : path; } -function setupActionsForInstalled(source: string, capletId: string): DashboardSetupAction[] { - try { - return setupActionsForEntry(dashboardCatalogDetail({ source, capletId }).entry); - } catch { - return []; - } -} - -function setupActionsForEntry(entry: CatalogEntry): DashboardSetupAction[] { +function setupActionsForEntry(entry: CatalogEntry): CurrentHostSetupAction[] { return setupActionsForFlags({ setupRequired: entry.setupReadiness === "required", authRequired: entry.authReadiness === "required", @@ -333,7 +284,7 @@ function setupActionsForFlags(input: { setupRequired: boolean; authRequired: boolean; projectBindingRequired: boolean; -}): DashboardSetupAction[] { +}): CurrentHostSetupAction[] { return [ ...(input.authRequired ? [{ kind: "auth" as const, label: "Connect required auth", required: true }] @@ -349,11 +300,14 @@ function setupActionsForFlags(input: { ]; } -function lockfilePath(context: DashboardCatalogContext): string { - return context.control?.globalLockfilePath ?? defaultCapletsLockfilePath(); +function authRequired(caplet: CapletConfig): boolean { + const auth = "auth" in caplet ? caplet.auth : undefined; + return Boolean(auth) && auth?.type !== "none"; } -type LockEntry = { id: string; source?: { type?: string; repository?: string }; risk?: unknown }; +function lockfilePath(context: CurrentHostCatalogContext): string { + return context.globalLockfilePath ?? defaultCapletsLockfilePath(); +} function safeLockEntries(path: string): Map { try { @@ -368,54 +322,6 @@ function safeLockEntries(path: string): Map { } } -function requiredControlContext(context: DashboardCatalogContext): RemoteControlDispatchContext { - if (!context.control) { - throw new CapletsError( - "SERVER_UNAVAILABLE", - "Dashboard catalog actions require server control context.", - ); - } - return context.control; -} - -function installedFromDispatchResult(result: unknown): DashboardInstalledCatalogCaplet[] { - if ( - !result || - typeof result !== "object" || - !Array.isArray((result as { installed?: unknown }).installed) - ) { - return []; - } - return (result as { installed: unknown[] }).installed.flatMap((entry) => { - if (!entry || typeof entry !== "object") return []; - const value = entry as Partial; - if ( - typeof value.id !== "string" || - typeof value.destination !== "string" || - typeof value.kind !== "string" - ) { - return []; - } - return [ - { - id: value.id, - source: typeof value.source === "string" ? value.source : "unknown", - destination: value.destination, - kind: value.kind === "directory" ? "directory" : "file", - ...(typeof value.hash === "string" ? { hash: value.hash } : {}), - ...(typeof value.status === "string" ? { status: value.status } : {}), - ...(typeof value.lockfile === "string" ? { lockfile: value.lockfile } : {}), - ...(value.catalogIndexing ? { catalogIndexing: value.catalogIndexing } : {}), - }, - ]; - }); -} - -function authRequired(caplet: CapletConfig): boolean { - const auth = "auth" in caplet ? caplet.auth : undefined; - return Boolean(auth) && auth?.type !== "none"; -} - function boundedLimit(limit: number | undefined): number { if (limit === undefined || !Number.isFinite(limit)) return 50; return Math.min(100, Math.max(1, Math.trunc(limit))); diff --git a/packages/core/src/current-host/client-operations.ts b/packages/core/src/current-host/client-operations.ts new file mode 100644 index 00000000..d4387bc6 --- /dev/null +++ b/packages/core/src/current-host/client-operations.ts @@ -0,0 +1,297 @@ +import { roleChangeMetadata } from "../dashboard/activity-log"; +import { CapletsError } from "../errors"; +import type { + CurrentHostOperation, + CurrentHostOperationOutcome, + CurrentHostOperatorPrincipal, + CurrentHostOperationsDependencies, +} from "./operations"; + +type SummaryOperation = Extract; +type ClientsListOutcome = Extract; +type PendingLoginsListOutcome = Extract< + CurrentHostOperationOutcome, + { kind: "pending_logins_list" } +>; +type PendingLoginApproveOperation = Extract< + CurrentHostOperation, + { kind: "pending_login_approve" } +>; +type PendingLoginApproveOutcome = Extract< + CurrentHostOperationOutcome, + { kind: "pending_login_approve" } +>; +type PendingLoginDenyOperation = Extract; +type PendingLoginDenyOutcome = Extract; +type ClientRevokeOperation = Extract; +type ClientRevokeOutcome = Extract; +type ClientChangeRoleOperation = Extract; +type ClientChangeRoleOutcome = Extract; +type SummaryOutcome = Extract; + +/** Current Host client and Pending Remote Login administration behind the facade. */ +export function createCurrentHostClientOperations(dependencies: CurrentHostOperationsDependencies) { + return { + summary: (vaultCount: number, operation: SummaryOperation): SummaryOutcome => + summaryOutcome(dependencies, vaultCount, operation), + listClients: (): ClientsListOutcome => ({ + kind: "clients_list", + clients: dependencies.remoteCredentialStore?.listClients() ?? [], + }), + listPendingLogins: (): PendingLoginsListOutcome => ({ + kind: "pending_logins_list", + pendingLogins: (dependencies.remoteCredentialStore?.listPendingLogins() ?? []).filter( + (login) => login.status === "pending" || login.status === "approved", + ), + }), + approvePendingLogin: ( + principal: CurrentHostOperatorPrincipal, + operation: PendingLoginApproveOperation, + ): PendingLoginApproveOutcome => + pendingLoginApprovalOutcome(dependencies, principal, operation), + denyPendingLogin: ( + principal: CurrentHostOperatorPrincipal, + operation: PendingLoginDenyOperation, + ): PendingLoginDenyOutcome => pendingLoginDenialOutcome(dependencies, principal, operation), + revokeClient: ( + principal: CurrentHostOperatorPrincipal, + operation: ClientRevokeOperation, + ): ClientRevokeOutcome => clientRevokeOutcome(dependencies, principal, operation), + changeClientRole: ( + principal: CurrentHostOperatorPrincipal, + operation: ClientChangeRoleOperation, + ): ClientChangeRoleOutcome => clientRoleOutcome(dependencies, principal, operation), + }; +} + +function summaryOutcome( + dependencies: CurrentHostOperationsDependencies, + vaultCount: number, + operation: SummaryOperation, +): SummaryOutcome { + const pendingLogins = dependencies.remoteCredentialStore?.listPendingLogins() ?? []; + const clients = dependencies.remoteCredentialStore?.listClients() ?? []; + const pending = pendingLogins.filter((login) => login.status === "pending"); + return { + kind: "summary", + summary: { + host: { + current: true, + baseUrl: operation.baseUrl, + dashboardUrl: operation.dashboardUrl, + version: dependencies.version, + roleModel: "current-host", + }, + attention: pending.map((login) => ({ + kind: "pending-login", + severity: "warning", + label: `${login.clientLabel} is waiting for ${login.requestedRole} approval`, + href: `${operation.dashboardPath}#access`, + })), + sections: { + caplets: { + count: dependencies.engine.enabledServers().length, + href: `${operation.dashboardPath}#caplets`, + }, + catalog: { href: `${operation.dashboardPath}#catalog` }, + access: { + clients: clients.length, + pending: pending.length, + href: `${operation.dashboardPath}#access`, + }, + vault: { count: vaultCount, href: `${operation.dashboardPath}#vault` }, + projectBinding: { + state: "disconnected", + href: `${operation.dashboardPath}#project-binding`, + }, + runtime: { status: "ok", href: `${operation.dashboardPath}#runtime` }, + logs: { href: `${operation.dashboardPath}#logs` }, + diagnostics: { href: `${operation.dashboardPath}#diagnostics` }, + activity: { href: `${operation.dashboardPath}#activity` }, + settings: { href: `${operation.dashboardPath}#settings` }, + }, + }, + }; +} + +function pendingLoginApprovalOutcome( + dependencies: CurrentHostOperationsDependencies, + principal: CurrentHostOperatorPrincipal, + operation: PendingLoginApproveOperation, +): PendingLoginApproveOutcome { + const flowId = requiredPendingLoginFlowId(operation.flowId); + if (operation.grantedRole !== undefined) assertRemoteClientRole(operation.grantedRole); + const store = dependencies.remoteCredentialStore; + if (!store) { + appendFailureActivity(dependencies, principal, "pending_login_approved", { + type: "pending_login", + id: flowId, + }); + return { kind: "pending_login_approve", status: "credential_store_unavailable" }; + } + try { + const pendingLogin = store.approvePendingLoginFlow({ + flowId, + ...(operation.grantedRole === undefined ? {} : { grantedRole: operation.grantedRole }), + }); + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "pending_login_approved", + target: { type: "pending_login", id: pendingLogin.flowId }, + metadata: { + requestedRole: pendingLogin.requestedRole, + grantedRole: pendingLogin.grantedRole ?? pendingLogin.requestedRole, + }, + }); + return { kind: "pending_login_approve", pendingLogin }; + } catch (error) { + appendFailureActivity(dependencies, principal, "pending_login_approved", { + type: "pending_login", + id: flowId, + }); + throw error; + } +} + +function pendingLoginDenialOutcome( + dependencies: CurrentHostOperationsDependencies, + principal: CurrentHostOperatorPrincipal, + operation: PendingLoginDenyOperation, +): PendingLoginDenyOutcome { + const flowId = requiredPendingLoginFlowId(operation.flowId); + const store = dependencies.remoteCredentialStore; + if (!store) { + appendFailureActivity(dependencies, principal, "pending_login_denied", { + type: "pending_login", + id: flowId, + }); + return { kind: "pending_login_deny", status: "credential_store_unavailable" }; + } + try { + const pendingLogin = store.denyPendingLoginFlow({ flowId }); + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "pending_login_denied", + target: { type: "pending_login", id: pendingLogin.flowId }, + metadata: { requestedRole: pendingLogin.requestedRole }, + }); + return { kind: "pending_login_deny", pendingLogin }; + } catch (error) { + appendFailureActivity(dependencies, principal, "pending_login_denied", { + type: "pending_login", + id: flowId, + }); + throw error; + } +} + +function clientRevokeOutcome( + dependencies: CurrentHostOperationsDependencies, + principal: CurrentHostOperatorPrincipal, + operation: ClientRevokeOperation, +): ClientRevokeOutcome { + const clientId = requiredRemoteClientId(operation.clientId); + const store = dependencies.remoteCredentialStore; + if (!store) { + appendFailureActivity(dependencies, principal, "remote_client_revoked", { + type: "remote_client", + id: clientId, + }); + return { kind: "client_revoke", status: "credential_store_unavailable", clientId }; + } + try { + const client = store.listClients().find((candidate) => candidate.clientId === clientId); + const revoked = store.revokeClient(clientId); + if (revoked && !client?.revokedAt) { + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "remote_client_revoked", + target: { type: "remote_client", id: clientId }, + metadata: { role: client?.role ?? null }, + }); + } + return { + kind: "client_revoke", + revoked, + clientId, + sessionEnded: revoked && clientId === principal.clientId, + }; + } catch (error) { + appendFailureActivity(dependencies, principal, "remote_client_revoked", { + type: "remote_client", + id: clientId, + }); + throw error; + } +} + +function clientRoleOutcome( + dependencies: CurrentHostOperationsDependencies, + principal: CurrentHostOperatorPrincipal, + operation: ClientChangeRoleOperation, +): ClientChangeRoleOutcome { + const clientId = requiredRemoteClientId(operation.clientId); + assertRemoteClientRole(operation.role); + const store = dependencies.remoteCredentialStore; + if (!store) { + appendFailureActivity(dependencies, principal, "remote_client_role_changed", { + type: "remote_client", + id: clientId, + }); + return { kind: "client_change_role", status: "credential_store_unavailable", clientId }; + } + try { + const before = store.listClients().find((candidate) => candidate.clientId === clientId); + const client = store.changeClientRole(clientId, operation.role); + if (!client) { + return { kind: "client_change_role", status: "not_found", clientId, sessionEnded: false }; + } + const sessionEnded = client.clientId === principal.clientId && client.role !== "operator"; + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "remote_client_role_changed", + target: { type: "remote_client", id: client.clientId }, + metadata: roleChangeMetadata(before?.role ?? client.role, client.role), + }); + return { kind: "client_change_role", status: "changed", client, sessionEnded }; + } catch (error) { + appendFailureActivity(dependencies, principal, "remote_client_role_changed", { + type: "remote_client", + id: clientId, + }); + throw error; + } +} + +function requiredRemoteClientId(value: unknown): string { + if (typeof value === "string" && /^rcli_[A-Za-z0-9_-]{16}$/u.test(value)) return value; + throw new CapletsError("REQUEST_INVALID", "Remote client ID is invalid."); +} + +function requiredPendingLoginFlowId(value: unknown): string { + if (typeof value === "string" && /^rlogin_[A-Za-z0-9_-]{16}$/u.test(value)) return value; + throw new CapletsError("REQUEST_INVALID", "Pending login flow ID is invalid."); +} + +function assertRemoteClientRole(value: unknown): asserts value is "access" | "operator" { + if (value === "access" || value === "operator") return; + throw new CapletsError("REQUEST_INVALID", "Remote client role is invalid."); +} + +function appendFailureActivity( + dependencies: CurrentHostOperationsDependencies, + principal: CurrentHostOperatorPrincipal, + action: + | "pending_login_approved" + | "pending_login_denied" + | "remote_client_revoked" + | "remote_client_role_changed", + target: { type: "pending_login" | "remote_client"; id: string }, +): void { + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action, + outcome: "failure", + target, + }); +} diff --git a/packages/core/src/current-host/operations.ts b/packages/core/src/current-host/operations.ts new file mode 100644 index 00000000..aa43b80c --- /dev/null +++ b/packages/core/src/current-host/operations.ts @@ -0,0 +1,553 @@ +import type { CatalogEntry } from "../catalog"; +import { + DashboardActivityLog, + type DashboardActivityAction, + type DashboardActivityEntry, +} from "../dashboard/activity-log"; +import type { CapletsEngine } from "../engine"; +import { CapletsError, toSafeError, type SafeErrorSummary } from "../errors"; +import type { RemoteServerCredentialStore } from "../remote/server-credential-store"; +import type { + RemoteClientRole, + RemoteClientStatus, + RemotePendingLoginStatus, +} from "../remote/server-credentials"; +import type { VaultDeleteStatus, VaultValueStatus } from "../vault"; +import { + type CurrentHostCatalogInstallResult as CatalogInstallResult, + type CurrentHostInstalledCapletProjection as InstalledCapletProjection, + type CurrentHostInstalledCatalogCaplet as InstalledCatalogCaplet, + type CurrentHostSetupAction as SetupAction, +} from "./catalog"; +import { createCurrentHostCatalogOperations } from "./catalog-operations"; +import { createCurrentHostClientOperations } from "./client-operations"; +import { createCurrentHostVaultOperations } from "./vault-operations"; + +const TRUSTED_DEVELOPMENT_PRINCIPAL = Symbol("trusted-development-principal"); + +export type CurrentHostPrincipal = { + clientId: string; + clientLabel?: string | undefined; + hostUrl: string; + role: RemoteClientRole; +}; + +export type CurrentHostOperatorPrincipal = CurrentHostPrincipal & { + role: "operator"; + [TRUSTED_DEVELOPMENT_PRINCIPAL]?: true; +}; + +export function trustedDevelopmentOperatorPrincipal(hostUrl: string): CurrentHostOperatorPrincipal { + if (!isHttpUrl(hostUrl)) { + throw new CapletsError("AUTH_FAILED", "Current Host administration requires a valid host URL."); + } + return { + clientId: "development_unauthenticated", + hostUrl, + role: "operator", + [TRUSTED_DEVELOPMENT_PRINCIPAL]: true, + }; +} + +export function toCurrentHostSafeError(error: unknown): SafeErrorSummary { + const safe = + error instanceof CapletsError + ? toSafeError(error, error.code) + : toSafeError( + new CapletsError("INTERNAL_ERROR", "Current Host administration failed."), + "INTERNAL_ERROR", + ); + return { + ...safe, + message: redactCurrentHostErrorMessage(safe.message), + }; +} + +function redactCurrentHostErrorMessage(message: string): string { + return message + .replace(/\b(?:https?|file):\/\/[^\s,;)"']+/giu, "[REDACTED]") + .replace( + /(["'])(authorization|(?:access[_-]?)?token|refresh(?:[_-]?token)?|password|client[_-]?secret|clientsecret|api[-_]?key|apikey|secret|credential|code)\1\s*:\s*(["'])(?:\\.|[^\\])*?\3/giu, + "$1$2$1:$3[REDACTED]$3", + ) + .replace(/\b(authorization\s*:\s*(?:basic|bearer)\s+)[^\s,;]+/giu, "$1[REDACTED]") + .replace( + /\b((?:access[_-]?)?token|refresh(?:[_-]?token)?|password|client[_-]?secret|clientsecret|api[-_]?key|apikey|secret|credential|code)(\s*[=:]\s*)[^\s,;]+/giu, + "$1$2[REDACTED]", + ) + .replace(/(^|[\s("'])\/[^\s,;:)"]+/gu, "$1[REDACTED]") + .replace(/\b[A-Za-z]:\\[^\s,;")]+/gu, "[REDACTED]"); +} + +export type CurrentHostControlContext = { + configPath?: string | undefined; + projectConfigPath?: string | undefined; + authDir?: string | undefined; + globalCapletsRoot?: string | undefined; + globalLockfilePath?: string | undefined; +}; + +export type CurrentHostOperation = + | { + kind: "summary"; + baseUrl: string; + dashboardUrl: string; + dashboardPath: string; + } + | { kind: "caplets_list" } + | { + kind: "catalog_search"; + source: string; + query?: string | undefined; + limit?: number | undefined; + } + | { kind: "catalog_detail"; source: string; capletId: string } + | { kind: "catalog_updates" } + | { + kind: "catalog_install"; + /** A dashboard catalog source. When present, it also determines setup actions. */ + source?: string | undefined; + /** A bearer-compatible repository source. Omit to restore the server lockfile. */ + repo?: string | undefined; + capletIds?: string[] | undefined; + force?: boolean | undefined; + disableCatalogIndexing?: boolean | undefined; + } + | { + kind: "catalog_update"; + capletIds?: string[] | undefined; + force?: boolean | undefined; + allowRiskIncrease?: boolean | undefined; + disableCatalogIndexing?: boolean | undefined; + } + | { kind: "clients_list" } + | { kind: "pending_logins_list" } + | { + kind: "pending_login_approve"; + flowId: string; + grantedRole?: RemoteClientRole | undefined; + } + | { kind: "pending_login_deny"; flowId: string } + | { kind: "client_revoke"; clientId: string } + | { kind: "client_change_role"; clientId: string; role: RemoteClientRole } + | { + kind: "activity_list"; + limit?: number | undefined; + after?: string | undefined; + action?: DashboardActivityAction | undefined; + } + | { + kind: "vault_set"; + name: string; + value: string; + grant?: string | undefined; + referenceName?: string | undefined; + force?: boolean | undefined; + } + | { kind: "vault_list" } + | { kind: "vault_get"; name: string } + | { kind: "vault_delete"; name: string } + | { + kind: "vault_access_grant"; + storedKey: string; + referenceName: string; + capletId: string; + } + | { + kind: "vault_access_revoke"; + storedKey: string; + referenceName?: string | undefined; + capletId?: string | undefined; + } + | { + kind: "vault_access_list"; + storedKey?: string | undefined; + capletId?: string | undefined; + } + | { + kind: "runtime"; + baseUrl: string; + bind: string; + publicOrigin?: string | null | undefined; + } + | { kind: "runtime_restart" } + | { kind: "logs"; limit?: number | undefined } + | { kind: "diagnostics" } + | { kind: "runtime_event" } + | { kind: "project_binding" }; + +export type CurrentHostVaultAccessGrant = { + storedKey: string; + referenceName: string; + capletId: string; + origin: { kind: string }; + createdAt: string; + updatedAt: string; +}; + +export type CurrentHostSetupAction = SetupAction; +export type CurrentHostInstalledCaplet = InstalledCapletProjection; +export type CurrentHostInstalledCatalogCaplet = InstalledCatalogCaplet; +export type CurrentHostCatalogInstallResult = CatalogInstallResult; + +export type CurrentHostSummarySections = { + caplets: { count: number; href: string }; + catalog: { href: string }; + access: { clients: number; pending: number; href: string }; + vault: { count: number; href: string }; + projectBinding: { state: "disconnected"; href: string }; + runtime: { status: "ok"; href: string }; + logs: { href: string }; + diagnostics: { href: string }; + activity: { href: string }; + settings: { href: string }; +}; + +export type CurrentHostSummary = { + host: { + current: true; + baseUrl: string; + dashboardUrl: string; + version: string; + roleModel: "current-host"; + }; + attention: Array<{ + kind: "pending-login"; + severity: "warning"; + label: string; + href: string; + }>; + sections: CurrentHostSummarySections; +}; + +export type CurrentHostActivityPage = { + entries: DashboardActivityEntry[]; + nextCursor?: string | undefined; +}; + +export type CurrentHostOperationOutcome = + | { kind: "summary"; summary: CurrentHostSummary } + | { kind: "caplets_list"; caplets: CurrentHostInstalledCaplet[] } + | { kind: "catalog_search"; entries: CatalogEntry[] } + | { + kind: "catalog_detail"; + entry: CatalogEntry; + setupActions: CurrentHostSetupAction[]; + projectScopedInstallAvailable: false; + } + | { kind: "catalog_updates"; updates: Array<{ id: string; status: "locked"; risk: unknown }> } + | { + kind: "catalog_install"; + installed: CurrentHostInstalledCatalogCaplet[]; + setupActions: CurrentHostSetupAction[]; + } + | { + kind: "catalog_update"; + installed: CurrentHostInstalledCatalogCaplet[]; + setupActions: CurrentHostSetupAction[]; + } + | { kind: "clients_list"; clients: RemoteClientStatus[] } + | { kind: "pending_logins_list"; pendingLogins: RemotePendingLoginStatus[] } + | { + kind: "pending_login_approve"; + pendingLogin: RemotePendingLoginStatus; + } + | { + kind: "pending_login_approve"; + status: "credential_store_unavailable"; + } + | { + kind: "pending_login_deny"; + pendingLogin: RemotePendingLoginStatus; + } + | { + kind: "pending_login_deny"; + status: "credential_store_unavailable"; + } + | { kind: "client_revoke"; revoked: boolean; clientId: string; sessionEnded: boolean } + | { kind: "client_revoke"; status: "credential_store_unavailable"; clientId: string } + | { + kind: "client_change_role"; + status: "not_found"; + clientId: string; + sessionEnded: false; + } + | { + kind: "client_change_role"; + status: "changed"; + client: RemoteClientStatus; + sessionEnded: boolean; + } + | { kind: "client_change_role"; status: "credential_store_unavailable"; clientId: string } + | { kind: "activity_list"; activity: CurrentHostActivityPage } + | { kind: "vault_set"; status: VaultValueStatus } + | { + kind: "vault_list"; + values: VaultValueStatus[]; + grants: CurrentHostVaultAccessGrant[]; + } + | { kind: "vault_get"; status: VaultValueStatus } + | { kind: "vault_delete"; deleted: VaultDeleteStatus } + | { kind: "vault_access_grant"; grant: CurrentHostVaultAccessGrant } + | { kind: "vault_access_revoke"; revoked: CurrentHostVaultAccessGrant[] } + | { kind: "vault_access_list"; grants: CurrentHostVaultAccessGrant[] } + | { + kind: "runtime"; + runtime: { + status: "ok"; + version: string; + bind: string; + baseUrl: string; + publicOrigin: string | null; + }; + daemon: { restartAvailable: false; stopAvailable: false; uninstallAvailable: false }; + } + | { kind: "runtime_restart"; restartAvailable: false; reason: "daemon_manager_unavailable" } + | { kind: "logs"; entries: []; limit: number; truncated: false } + | { + kind: "diagnostics"; + status: "ok"; + diagnostics: []; + checks: Array<{ id: "runtime" | "dashboard"; status: "ok" }>; + } + | { + kind: "runtime_event"; + event: { + type: "runtime_health"; + runtime: { status: "ok"; version: string }; + projectBinding: { state: "disconnected" }; + }; + } + | { + kind: "project_binding"; + projectBinding: { + state: "disconnected"; + affectedCaplets: []; + actions: Array<{ id: string; label: string; enabled: false; reason: string }>; + }; + }; + +/** The outcome family determined by a semantic Current Host operation's discriminant. */ +export type CurrentHostOperationOutcomeFor = Extract< + CurrentHostOperationOutcome, + { kind: TOperation["kind"] } +>; + +/** + * The single app-scoped Current Host administration boundary. Adapters authenticate + * and serialize; this Module owns safe administration policy and outcomes. + */ +export interface CurrentHostOperations { + execute( + principal: CurrentHostPrincipal, + operation: TOperation, + ): Promise>; +} + +export type CurrentHostOperationsDependencies = { + engine: Pick; + control?: CurrentHostControlContext | undefined; + activityLog: DashboardActivityLog; + remoteCredentialStore?: RemoteServerCredentialStore | undefined; + version: string; +}; + +export function createCurrentHostOperations( + dependencies: CurrentHostOperationsDependencies, +): CurrentHostOperations { + const catalog = createCurrentHostCatalogOperations(dependencies); + const clients = createCurrentHostClientOperations(dependencies); + const vault = createCurrentHostVaultOperations(dependencies); + + return { + async execute( + principal: CurrentHostPrincipal, + operation: TOperation, + ): Promise> { + assertOperatorPrincipal(principal); + const outcome = await executeCurrentHostOperation( + dependencies, + catalog, + clients, + vault, + principal, + operation, + ); + return outcome as CurrentHostOperationOutcomeFor; + }, + }; +} + +async function executeCurrentHostOperation( + dependencies: CurrentHostOperationsDependencies, + catalog: ReturnType, + clients: ReturnType, + vault: ReturnType, + principal: CurrentHostOperatorPrincipal, + operation: CurrentHostOperation, +): Promise { + switch (operation.kind) { + case "summary": + return clients.summary(vault.valueCount(), operation); + case "caplets_list": + return catalog.capletsList(operation); + case "catalog_search": + return catalog.search(operation); + case "catalog_detail": + return catalog.detail(operation); + case "catalog_updates": + return catalog.updates(operation); + case "catalog_install": + return await catalog.install(principal, operation); + case "catalog_update": + return await catalog.update(principal, operation); + case "clients_list": + return clients.listClients(); + case "pending_logins_list": + return clients.listPendingLogins(); + case "pending_login_approve": + return clients.approvePendingLogin(principal, operation); + case "pending_login_deny": + return clients.denyPendingLogin(principal, operation); + case "client_revoke": + return clients.revokeClient(principal, operation); + case "client_change_role": + return clients.changeClientRole(principal, operation); + case "activity_list": + return { kind: "activity_list", activity: dependencies.activityLog.list(operation) }; + case "vault_set": + return vault.set(principal, operation); + case "vault_list": + return vault.list(operation); + case "vault_get": + return vault.get(operation); + case "vault_delete": + return vault.delete(principal, operation); + case "vault_access_grant": + return vault.grant(principal, operation); + case "vault_access_revoke": + return vault.revoke(principal, operation); + case "vault_access_list": + return vault.listAccess(operation); + case "runtime": + return { + kind: "runtime", + runtime: { + status: "ok", + version: dependencies.version, + bind: operation.bind, + baseUrl: operation.baseUrl, + publicOrigin: operation.publicOrigin ?? null, + }, + daemon: { restartAvailable: false, stopAvailable: false, uninstallAvailable: false }, + }; + case "runtime_restart": + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "runtime_restart_requested", + outcome: "failure", + target: { type: "runtime", id: "current-host" }, + metadata: { reason: "daemon_manager_unavailable" }, + }); + return { + kind: "runtime_restart", + restartAvailable: false, + reason: "daemon_manager_unavailable", + }; + case "logs": + return { + kind: "logs", + entries: [], + limit: boundedLimit(operation.limit), + truncated: false, + }; + case "diagnostics": + return { + kind: "diagnostics", + status: "ok", + diagnostics: [], + checks: [ + { id: "runtime", status: "ok" }, + { id: "dashboard", status: "ok" }, + ], + }; + case "runtime_event": + return { + kind: "runtime_event", + event: { + type: "runtime_health", + runtime: { status: "ok", version: dependencies.version }, + projectBinding: { state: "disconnected" }, + }, + }; + case "project_binding": + return { + kind: "project_binding", + projectBinding: { + state: "disconnected", + affectedCaplets: [], + actions: [ + { + id: "attach-project", + label: "Attach project from a client", + enabled: false, + reason: "Project Binding sessions are started by Access Clients.", + }, + ], + }, + }; + default: + return assertNever(operation); + } +} + +function boundedLimit(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return 100; + return Math.min(500, Math.max(1, Math.trunc(value))); +} + +function assertOperatorPrincipal( + principal: unknown, +): asserts principal is CurrentHostOperatorPrincipal { + if ( + !isRecord(principal) || + principal.role !== "operator" || + (principal.clientId !== "development_unauthenticated" && + (typeof principal.clientId !== "string" || + !/^rcli_[A-Za-z0-9_-]{16}$/u.test(principal.clientId))) || + (principal.clientId === "development_unauthenticated" && + Reflect.get(principal, TRUSTED_DEVELOPMENT_PRINCIPAL) !== true) || + typeof principal.hostUrl !== "string" || + !isHttpUrl(principal.hostUrl) || + (principal.clientLabel !== undefined && typeof principal.clientLabel !== "string") + ) { + throw new CapletsError( + "AUTH_FAILED", + "Current Host administration requires an Operator principal.", + ); + } +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + url.username === "" && + url.password === "" + ); + } catch { + return false; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertNever(value: never): never { + throw new CapletsError( + "UNKNOWN_OPERATION", + `Unsupported Current Host operation ${String(value)}`, + ); +} diff --git a/packages/core/src/current-host/vault-operations.ts b/packages/core/src/current-host/vault-operations.ts new file mode 100644 index 00000000..a81cb77a --- /dev/null +++ b/packages/core/src/current-host/vault-operations.ts @@ -0,0 +1,302 @@ +import { + loadLocalOverlayConfigWithSources, + vaultBootstrapResolver, + vaultStoreForAuthDir, +} from "../config"; +import { SERVER_ID_PATTERN } from "../config/validation"; +import { CapletsError } from "../errors"; +import { FileVaultStore, validateVaultKeyName, type VaultAccessGrantInput } from "../vault"; +import type { + CurrentHostOperation, + CurrentHostOperationOutcome, + CurrentHostOperatorPrincipal, + CurrentHostOperationsDependencies, + CurrentHostVaultAccessGrant, +} from "./operations"; + +type VaultSetOperation = Extract; +type VaultListOperation = Extract; +type VaultGetOperation = Extract; +type VaultDeleteOperation = Extract; +type VaultAccessGrantOperation = Extract; +type VaultAccessRevokeOperation = Extract; +type VaultAccessListOperation = Extract; +type VaultSetOutcome = Extract; +type VaultListOutcome = Extract; +type VaultGetOutcome = Extract; +type VaultDeleteOutcome = Extract; +type VaultAccessGrantOutcome = Extract; +type VaultAccessRevokeOutcome = Extract< + CurrentHostOperationOutcome, + { kind: "vault_access_revoke" } +>; +type VaultAccessListOutcome = Extract; + +/** Safe Vault administration implementation. Raw value reveal remains in the dashboard adapter. */ +export function createCurrentHostVaultOperations(dependencies: CurrentHostOperationsDependencies) { + const vault = vaultStoreForAuthDir(dependencies.control?.authDir); + + return { + valueCount: (): number => vault.listValues().length, + list: (_operation: VaultListOperation): VaultListOutcome => ({ + kind: "vault_list", + values: vault.listValues(), + grants: vault.listAccess().map(redactedVaultGrant), + }), + get: (operation: VaultGetOperation): VaultGetOutcome => ({ + kind: "vault_get", + status: vault.getStatus(validateVaultKeyName(operation.name)), + }), + set: (principal: CurrentHostOperatorPrincipal, operation: VaultSetOperation): VaultSetOutcome => + vaultSetOutcome(dependencies, vault, principal, operation), + delete: ( + principal: CurrentHostOperatorPrincipal, + operation: VaultDeleteOperation, + ): VaultDeleteOutcome => vaultDeleteOutcome(dependencies, vault, principal, operation), + grant: ( + principal: CurrentHostOperatorPrincipal, + operation: VaultAccessGrantOperation, + ): VaultAccessGrantOutcome => vaultGrantOutcome(dependencies, vault, principal, operation), + revoke: ( + principal: CurrentHostOperatorPrincipal, + operation: VaultAccessRevokeOperation, + ): VaultAccessRevokeOutcome => vaultRevokeOutcome(dependencies, vault, principal, operation), + listAccess: (operation: VaultAccessListOperation): VaultAccessListOutcome => ({ + kind: "vault_access_list", + grants: vault + .listAccess({ + ...(operation.storedKey === undefined + ? {} + : { storedKey: validateVaultKeyName(operation.storedKey) }), + ...(operation.capletId === undefined + ? {} + : { capletId: requiredCapletId(operation.capletId) }), + }) + .map(redactedVaultGrant), + }), + }; +} + +function vaultSetOutcome( + dependencies: CurrentHostOperationsDependencies, + vault: FileVaultStore, + principal: CurrentHostOperatorPrincipal, + operation: VaultSetOperation, +): VaultSetOutcome { + const storedKey = validateVaultKeyName(operation.name); + const capletId = operation.grant === undefined ? undefined : requiredCapletId(operation.grant); + const referenceName = + capletId === undefined + ? undefined + : validateVaultKeyName( + operation.referenceName === undefined ? storedKey : operation.referenceName, + ); + try { + const grantInput = + capletId === undefined || referenceName === undefined + ? undefined + : ({ + storedKey, + referenceName, + capletId, + origin: vaultAccessOrigin(capletId, dependencies), + } satisfies VaultAccessGrantInput); + const existed = vault.getStatus(storedKey).present; + const previousValue = existed && grantInput ? vault.resolveValue(storedKey) : undefined; + const status = vault.set(storedKey, operation.value, { force: operation.force ?? false }); + let grant; + try { + grant = grantInput ? vault.grantAccess(grantInput) : undefined; + } catch (error) { + if (existed && previousValue !== undefined) { + vault.set(storedKey, previousValue, { force: true }); + } else { + vault.delete(storedKey); + } + throw error; + } + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "vault_set", + target: { type: "vault", id: status.key }, + metadata: { bytesWritten: status.valueBytes ?? null }, + }); + if (grant) { + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "vault_grant_added", + target: { type: "vault", id: grant.storedKey }, + metadata: { + referenceName: grant.referenceName, + capletId: grant.capletId, + originKind: grant.origin.kind, + }, + }); + } + return { kind: "vault_set", status }; + } catch (error) { + appendFailureActivity(dependencies, principal, "vault_set", { type: "vault", id: storedKey }); + throw error; + } +} + +function vaultDeleteOutcome( + dependencies: CurrentHostOperationsDependencies, + vault: FileVaultStore, + principal: CurrentHostOperatorPrincipal, + operation: VaultDeleteOperation, +): VaultDeleteOutcome { + const storedKey = validateVaultKeyName(operation.name); + try { + const deleted = vault.delete(storedKey); + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "vault_deleted", + target: { type: "vault", id: deleted.key }, + metadata: { deleted: deleted.deleted, grantsRetained: deleted.grantsRetained }, + }); + return { kind: "vault_delete", deleted }; + } catch (error) { + appendFailureActivity(dependencies, principal, "vault_deleted", { + type: "vault", + id: storedKey, + }); + throw error; + } +} + +function vaultGrantOutcome( + dependencies: CurrentHostOperationsDependencies, + vault: FileVaultStore, + principal: CurrentHostOperatorPrincipal, + operation: VaultAccessGrantOperation, +): VaultAccessGrantOutcome { + const storedKey = validateVaultKeyName(operation.storedKey); + const referenceName = validateVaultKeyName(operation.referenceName); + const capletId = requiredCapletId(operation.capletId); + try { + const grant = vault.grantAccess({ + storedKey, + referenceName, + capletId, + origin: vaultAccessOrigin(capletId, dependencies), + }); + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "vault_grant_added", + target: { type: "vault", id: grant.storedKey }, + metadata: { + referenceName: grant.referenceName, + capletId: grant.capletId, + originKind: grant.origin.kind, + }, + }); + return { kind: "vault_access_grant", grant: redactedVaultGrant(grant) }; + } catch (error) { + appendFailureActivity(dependencies, principal, "vault_grant_added", { + type: "vault", + id: storedKey, + }); + throw error; + } +} + +function vaultRevokeOutcome( + dependencies: CurrentHostOperationsDependencies, + vault: FileVaultStore, + principal: CurrentHostOperatorPrincipal, + operation: VaultAccessRevokeOperation, +): VaultAccessRevokeOutcome { + const storedKey = validateVaultKeyName(operation.storedKey); + const referenceName = + operation.referenceName === undefined + ? undefined + : validateVaultKeyName(operation.referenceName); + const capletId = + operation.capletId === undefined ? undefined : requiredCapletId(operation.capletId); + try { + const revoked = vault.revokeAccess({ + storedKey, + ...(referenceName === undefined ? {} : { referenceName }), + ...(capletId === undefined ? {} : { capletId }), + }); + for (const grant of revoked) { + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action: "vault_grant_revoked", + target: { type: "vault", id: grant.storedKey }, + metadata: { + referenceName: grant.referenceName, + capletId: grant.capletId, + originKind: grant.origin.kind, + }, + }); + } + return { kind: "vault_access_revoke", revoked: revoked.map(redactedVaultGrant) }; + } catch (error) { + appendFailureActivity(dependencies, principal, "vault_grant_revoked", { + type: "vault", + id: storedKey, + }); + throw error; + } +} + +function vaultAccessOrigin(capletId: string, dependencies: CurrentHostOperationsDependencies) { + const control = dependencies.control; + if (!control) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + "Vault access grants require server control context.", + ); + } + const overlay = loadLocalOverlayConfigWithSources(control.configPath, control.projectConfigPath, { + vaultResolver: vaultBootstrapResolver, + }); + const origin = overlay.sources[capletId]; + if (!origin) throw new CapletsError("SERVER_NOT_FOUND", `Caplet ${capletId} is not configured.`); + if (overlay.shadows[capletId]?.length) { + throw new CapletsError( + "REQUEST_INVALID", + `Caplet ${capletId} is shadowed in multiple config sources; resolve the active config before granting Vault access.`, + ); + } + return origin; +} + +function requiredCapletId(value: unknown): string { + if (typeof value === "string" && SERVER_ID_PATTERN.test(value)) return value; + throw new CapletsError("REQUEST_INVALID", "Caplet ID is invalid."); +} + +function redactedVaultGrant(grant: { + storedKey: string; + referenceName: string; + capletId: string; + origin: { kind: string }; + createdAt: string; + updatedAt: string; +}): CurrentHostVaultAccessGrant { + return { + storedKey: grant.storedKey, + referenceName: grant.referenceName, + capletId: grant.capletId, + origin: { kind: grant.origin.kind }, + createdAt: grant.createdAt, + updatedAt: grant.updatedAt, + }; +} + +function appendFailureActivity( + dependencies: CurrentHostOperationsDependencies, + principal: CurrentHostOperatorPrincipal, + action: "vault_set" | "vault_deleted" | "vault_grant_added" | "vault_grant_revoked", + target: { type: "vault"; id: string }, +): void { + dependencies.activityLog.append({ + actorClientId: principal.clientId, + action, + outcome: "failure", + target, + }); +} diff --git a/packages/core/src/remote-control/dispatch.ts b/packages/core/src/remote-control/dispatch.ts index 7e975bc3..059cdd98 100644 --- a/packages/core/src/remote-control/dispatch.ts +++ b/packages/core/src/remote-control/dispatch.ts @@ -16,28 +16,17 @@ import { } from "./../cli/auth"; import { completionShells, type CompletionShell } from "./../cli/completion"; import { initConfig } from "./../cli/init"; -import { - installCaplets, - indexInstalledCapletsFromLockfile, - restoreCapletsFromLockfile, - updateCapletsFromLockfile, -} from "./../cli/install"; -import type { CatalogIndexingResult } from "../catalog-indexing/payload"; import { listCaplets } from "./../cli/inspection"; -import { - loadConfigWithSources, - loadLocalOverlayConfigWithSources, - defaultCapletsLockfilePath, - resolveCapletsRoot, - vaultBootstrapResolver, - vaultResolverForAuthDir, - vaultStoreForAuthDir, -} from "../config"; +import { loadConfigWithSources, vaultBootstrapResolver, vaultResolverForAuthDir } from "../config"; import { CapletsEngine, type CapletsEngineOptions } from "../engine"; import { CapletsError, toSafeError } from "../errors"; import { startGenericOAuthFlow, startOAuthFlow } from "../auth"; -import { FileVaultStore, validateVaultKeyName, type VaultAccessGrantInput } from "../vault"; import type { RemoteAuthFlowStore } from "./auth-flow"; +import { + toCurrentHostSafeError, + type CurrentHostOperatorPrincipal, + type CurrentHostOperations, +} from "../current-host/operations"; import type { RemoteCliRequest, RemoteCliResponse } from "./types"; export type RemoteControlDispatchContext = CapletsEngineOptions & { @@ -74,28 +63,39 @@ const ENGINE_COMMANDS = new Set([ "complete", ]); +type CurrentHostRemoteAdministration = { + operations: CurrentHostOperations; + principal: CurrentHostOperatorPrincipal; +}; + export async function dispatchRemoteCliRequest( request: RemoteCliRequest, context: RemoteControlDispatchContext, + currentHostAdministration?: CurrentHostRemoteAdministration | undefined, ): Promise { try { - const result = await dispatch(request, context); + const result = await dispatch(request, context, currentHostAdministration); return { ok: true, result }; } catch (error) { - const safe = toSafeError(error); + const currentHostOperation = isCurrentHostAdministrationCommand(request.command); + const safe = currentHostOperation ? toCurrentHostSafeError(error) : toSafeError(error); const action = nextAction(safe.details); return { ok: false, error: { code: safe.code, - message: redactControlErrorMessage(safe.message), + message: currentHostOperation ? safe.message : redactControlErrorMessage(safe.message), ...(action ? { nextAction: action } : {}), }, }; } } -async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatchContext) { +async function dispatch( + request: RemoteCliRequest, + context: RemoteControlDispatchContext, + currentHostAdministration?: CurrentHostRemoteAdministration | undefined, +) { assertObject(request, "remote control request"); assertObject(request.arguments, "remote control request arguments"); @@ -133,61 +133,35 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc return dispatchAdd(request.arguments, context); } - const globalCatalogTarget = () => ({ - destinationRoot: context.globalCapletsRoot ?? resolveCapletsRoot(context.configPath), - lockfilePath: context.globalLockfilePath ?? defaultCapletsLockfilePath(), - }); - if (request.command === "install") { + const administration = requireCurrentHostAdministration(currentHostAdministration); const repo = optionalString(request.arguments, "repo"); - if (!repo) { - const result = { - remote: true, - ...restoreCapletsFromLockfile({ - ...optionalProp("capletIds", optionalStringArray(request.arguments, "capletIds")), - ...globalCatalogTarget(), - ...optionalProp("force", optionalBoolean(request.arguments, "force")), - }), - }; - await attachRemoteCatalogIndexingResults( - result.installed, - optionalBoolean(request.arguments, "disableCatalogIndexing") ?? false, - ); - return result; - } - const result = { - remote: true, - ...installCaplets(repo, { - ...optionalProp("capletIds", optionalStringArray(request.arguments, "capletIds")), - ...globalCatalogTarget(), - ...optionalProp("force", optionalBoolean(request.arguments, "force")), - }), - }; - await attachRemoteCatalogIndexingResults( - result.installed, - optionalBoolean(request.arguments, "disableCatalogIndexing") ?? false, - ); - return result; + const outcome = await administration.operations.execute(administration.principal, { + kind: "catalog_install", + ...(repo ? { repo } : {}), + ...optionalProp("capletIds", optionalStringArray(request.arguments, "capletIds")), + ...optionalProp("force", optionalBoolean(request.arguments, "force")), + ...optionalProp( + "disableCatalogIndexing", + optionalBoolean(request.arguments, "disableCatalogIndexing"), + ), + }); + return { remote: true, installed: outcome.installed }; } if (request.command === "update") { - const result = { - remote: true, - ...updateCapletsFromLockfile({ - ...optionalProp("capletIds", optionalStringArray(request.arguments, "capletIds")), - ...globalCatalogTarget(), - ...optionalProp("force", optionalBoolean(request.arguments, "force")), - ...optionalProp( - "allowRiskIncrease", - optionalBoolean(request.arguments, "allowRiskIncrease"), - ), - }), - }; - await attachRemoteCatalogIndexingResults( - result.installed, - optionalBoolean(request.arguments, "disableCatalogIndexing") ?? false, - ); - return result; + const administration = requireCurrentHostAdministration(currentHostAdministration); + const outcome = await administration.operations.execute(administration.principal, { + kind: "catalog_update", + ...optionalProp("capletIds", optionalStringArray(request.arguments, "capletIds")), + ...optionalProp("force", optionalBoolean(request.arguments, "force")), + ...optionalProp("allowRiskIncrease", optionalBoolean(request.arguments, "allowRiskIncrease")), + ...optionalProp( + "disableCatalogIndexing", + optionalBoolean(request.arguments, "disableCatalogIndexing"), + ), + }); + return { remote: true, installed: outcome.installed }; } if (request.command === "complete_cli") { @@ -209,7 +183,19 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc } if (request.command.startsWith("vault_")) { - return dispatchVault(request, context); + if ( + request.command === "vault_get" && + (optionalBoolean(request.arguments, "reveal") ?? false) + ) { + throw new CapletsError( + "REQUEST_INVALID", + "Self-hosted remote Vault reveal is not supported through remote control.", + ); + } + return await dispatchCurrentHostVault( + request, + requireCurrentHostAdministration(currentHostAdministration), + ); } if (request.command === "auth_logout") { @@ -244,90 +230,83 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc ); } -async function attachRemoteCatalogIndexingResults( - installed: Array<{ - id: string; - lockfile?: string | undefined; - catalogIndexing?: CatalogIndexingResult | undefined; - }>, - disableCatalogIndexing: boolean, -): Promise { - const results = await indexInstalledCapletsFromLockfile(installed, { disableCatalogIndexing }); - for (const entry of installed) { - entry.catalogIndexing = results.get(entry.id); - } +function isCurrentHostAdministrationCommand(command: unknown): boolean { + return command === "install" || command === "update" || String(command).startsWith("vault_"); +} + +function requireCurrentHostAdministration( + administration: CurrentHostRemoteAdministration | undefined, +): CurrentHostRemoteAdministration { + if (administration) return administration; + throw new CapletsError( + "AUTH_FAILED", + "Current Host administration requires an Operator principal.", + ); } -function dispatchVault(request: RemoteCliRequest, context: RemoteControlDispatchContext) { - const store = remoteVaultStore(context); +async function dispatchCurrentHostVault( + request: RemoteCliRequest, + administration: CurrentHostRemoteAdministration, +) { switch (request.command) { case "vault_set": { - const name = requiredString(request.arguments, "name"); - const value = requiredString(request.arguments, "value"); - const grant = optionalString(request.arguments, "grant"); - const grantInput = grant - ? ({ - storedKey: validateVaultKeyName(name), - referenceName: validateVaultKeyName( - optionalString(request.arguments, "referenceName") ?? name, - ), - capletId: grant, - origin: remoteVaultAccessOrigin(grant, context), - } satisfies VaultAccessGrantInput) - : undefined; - const existed = store.getStatus(name).present; - const previousValue = existed && grantInput ? store.resolveValue(name) : undefined; - const status = store.set(name, value, { - force: optionalBoolean(request.arguments, "force") ?? false, + const outcome = await administration.operations.execute(administration.principal, { + kind: "vault_set", + name: requiredString(request.arguments, "name"), + value: requiredString(request.arguments, "value"), + ...optionalProp("grant", optionalString(request.arguments, "grant")), + ...optionalProp("referenceName", optionalString(request.arguments, "referenceName")), + ...optionalProp("force", optionalBoolean(request.arguments, "force")), + }); + return { remote: true, ...outcome.status }; + } + case "vault_list": { + const outcome = await administration.operations.execute(administration.principal, { + kind: "vault_list", }); - try { - if (grantInput) store.grantAccess(grantInput); - } catch (error) { - if (existed && previousValue !== undefined) { - store.set(name, previousValue, { force: true }); - } else { - store.delete(name); - } - throw error; - } - return { remote: true, ...status }; + return outcome.values; } - case "vault_list": - return store.listValues(); case "vault_get": { - const name = requiredString(request.arguments, "name"); - const reveal = optionalBoolean(request.arguments, "reveal") ?? false; - if (reveal) { - throw new CapletsError( - "REQUEST_INVALID", - "Self-hosted remote Vault reveal is not supported through remote control.", - ); - } - return store.getStatus(name); + const outcome = await administration.operations.execute(administration.principal, { + kind: "vault_get", + name: requiredString(request.arguments, "name"), + }); + return outcome.status; + } + case "vault_delete": { + const outcome = await administration.operations.execute(administration.principal, { + kind: "vault_delete", + name: requiredString(request.arguments, "name"), + }); + return outcome.deleted; } - case "vault_delete": - return store.delete(requiredString(request.arguments, "name")); case "vault_access_grant": { const storedKey = requiredString(request.arguments, "name"); - const capletId = requiredString(request.arguments, "capletId"); - return store.grantAccess({ + const outcome = await administration.operations.execute(administration.principal, { + kind: "vault_access_grant", storedKey, referenceName: optionalString(request.arguments, "referenceName") ?? storedKey, - capletId, - origin: remoteVaultAccessOrigin(capletId, context), + capletId: requiredString(request.arguments, "capletId"), }); + return outcome.grant; } - case "vault_access_revoke": - return store.revokeAccess({ + case "vault_access_revoke": { + const outcome = await administration.operations.execute(administration.principal, { + kind: "vault_access_revoke", storedKey: requiredString(request.arguments, "name"), capletId: requiredString(request.arguments, "capletId"), ...optionalProp("referenceName", optionalString(request.arguments, "referenceName")), }); - case "vault_access_list": - return store.listAccess({ + return outcome.revoked; + } + case "vault_access_list": { + const outcome = await administration.operations.execute(administration.principal, { + kind: "vault_access_list", ...optionalProp("storedKey", optionalString(request.arguments, "name")), ...optionalProp("capletId", optionalString(request.arguments, "capletId")), }); + return outcome.grants; + } default: throw new CapletsError( "UNKNOWN_OPERATION", @@ -336,25 +315,6 @@ function dispatchVault(request: RemoteCliRequest, context: RemoteControlDispatch } } -function remoteVaultStore(context: RemoteControlDispatchContext): FileVaultStore { - return vaultStoreForAuthDir(context.authDir); -} - -function remoteVaultAccessOrigin(capletId: string, context: RemoteControlDispatchContext) { - const overlay = loadLocalOverlayConfigWithSources(context.configPath, context.projectConfigPath, { - vaultResolver: vaultBootstrapResolver, - }); - const origin = overlay.sources[capletId]; - if (!origin) throw new CapletsError("SERVER_NOT_FOUND", `Caplet ${capletId} is not configured.`); - if (overlay.shadows[capletId]?.length) { - throw new CapletsError( - "REQUEST_INVALID", - `Caplet ${capletId} is shadowed in multiple config sources; resolve the active config before granting Vault access.`, - ); - } - return origin; -} - async function startRemoteAuthLogin(serverId: string, context: RemoteControlDispatchContext) { if (!context.authFlowStore || !context.controlCallbackBaseUrl) { throw new CapletsError("REQUEST_INVALID", "Remote auth login is not available on this server"); diff --git a/packages/core/src/remote-control/types.ts b/packages/core/src/remote-control/types.ts index 12eac83b..093cf8f2 100644 --- a/packages/core/src/remote-control/types.ts +++ b/packages/core/src/remote-control/types.ts @@ -34,8 +34,12 @@ export type RemoteCliCommand = | "vault_access_revoke" | "vault_access_list"; +/** + * Parsed wire input. `command` stays open so the adapter can safely envelope + * unknown protocol commands instead of forcing callers to bypass the type system. + */ export type RemoteCliRequest = { - command: RemoteCliCommand; + command: string; arguments: Record; }; diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index ffdf61a3..4b352cf5 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -17,27 +17,21 @@ import { resolveCapletsRoot, resolveProjectCapletsRoot, vaultStoreForAuthDir, - type ConfigSourceKind, } from "../config"; import { version as packageJsonVersion } from "../../package.json"; import { CapletsEngine, type CapletsEngineOptions } from "../engine"; import { CapletsError, toSafeError, type CapletsErrorCode } from "../errors"; -import { dashboardSessionCookie, expiredDashboardSessionCookie } from "../dashboard/auth"; -import { - dashboardCatalogDetail, - dashboardCatalogSearch, - dashboardInstallCatalogCaplet, - dashboardInstalledCaplets, - dashboardUpdateCatalogCaplet, - dashboardUpdateReadiness, -} from "../dashboard/catalog"; import { - DashboardActivityLog, - type DashboardActivityAction, - roleChangeMetadata, -} from "../dashboard/activity-log"; + createCurrentHostOperations, + toCurrentHostSafeError, + trustedDevelopmentOperatorPrincipal, + type CurrentHostOperatorPrincipal, +} from "../current-host/operations"; +import { dashboardSessionCookie, expiredDashboardSessionCookie } from "../dashboard/auth"; +import { DashboardActivityLog, type DashboardActivityAction } from "../dashboard/activity-log"; import { dashboardShell, dashboardStaticResponse } from "../dashboard/routes"; import { DashboardSessionStore } from "../dashboard/session-store"; +import type { DashboardSessionView } from "../dashboard/types"; import { attachErrorResponse, buildAttachProjection, @@ -64,7 +58,7 @@ import { import { RemoteAuthFlowStore } from "../remote-control/auth-flow"; import type { RemoteCliRequest } from "../remote-control/types"; import { RemoteServerCredentialStore } from "../remote/server-credential-store"; -import type { RemoteClientRole } from "../remote/server-credentials"; +import type { RemoteClientRole, ValidatedRemoteClient } from "../remote/server-credentials"; import { isLoopbackHost } from "../server/options"; import type { HttpServeOptions } from "./options"; import { CapletsMcpSession } from "./session"; @@ -180,6 +174,27 @@ export function createHttpServeApp( dir: dashboardStateDir, }); const dashboardActivityLog = new DashboardActivityLog({ dir: dashboardStateDir }); + const currentHostOperations = createCurrentHostOperations({ + engine, + ...(io.control === undefined + ? {} + : { + control: { + configPath: io.control.configPath, + projectConfigPath: io.control.projectConfigPath, + authDir: io.control.authDir, + globalCapletsRoot: io.control.globalCapletsRoot, + globalLockfilePath: io.control.globalLockfilePath, + }, + }), + activityLog: dashboardActivityLog, + remoteCredentialStore, + version: packageJsonVersion, + }); + const authenticatedRemoteClients = new WeakMap(); + const retainAuthenticatedRemoteClient = (request: Request, client: ValidatedRemoteClient) => { + authenticatedRemoteClients.set(request, client); + }; const projectBindingWorkspaceStore = io.projectBindingWorkspaceStore ?? new ProjectBindingWorkspaceStore(); if ( @@ -192,8 +207,27 @@ export function createHttpServeApp( "Remote credential auth with --trust-proxy requires CAPLETS_SERVER_URL.", ); } - const protectedRouteAuth = routeAuth(options, remoteCredentialStore, paths.base); - const operatorRouteAuth = routeAuth(options, remoteCredentialStore, paths.base, "operator"); + const authenticatedClientRouteAuth = routeAuth( + options, + remoteCredentialStore, + paths.base, + undefined, + retainAuthenticatedRemoteClient, + ); + const accessRouteAuth = routeAuth( + options, + remoteCredentialStore, + paths.base, + "access", + retainAuthenticatedRemoteClient, + ); + const operatorRouteAuth = routeAuth( + options, + remoteCredentialStore, + paths.base, + "operator", + retainAuthenticatedRemoteClient, + ); const attachHostProtection = dnsRebindingProtection(options); app.use( "*", @@ -324,142 +358,235 @@ export function createHttpServeApp( }); app.get(paths.dashboardSession, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; return c.json({ authenticated: true, session: session.session }); }); - app.get(paths.dashboardSummary, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardSummary, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - return c.json(dashboardSummary(c.req.url, (name) => c.req.header(name))); + try { + const baseUrl = remoteCredentialHostUrl(c.req.url, paths.base, options, (name) => + c.req.header(name), + ); + const dashboardUrl = new URL( + paths.dashboard, + publicRequestOrigin(c.req.url, options.trustProxy, (name) => c.req.header(name)), + ).toString(); + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "summary", baseUrl, dashboardUrl, dashboardPath: paths.dashboard }, + ); + if (outcome.kind !== "summary") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host summary returned an invalid outcome.", + ); + } + return c.json(outcome.summary); + } catch (error) { + return currentHostErrorResponse(error); + } }); - app.get(paths.dashboardCaplets, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardCaplets, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - return c.json({ - caplets: dashboardInstalledCaplets(engine.enabledServers(), dashboardCatalogContext()), - }); + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "caplets_list" }, + ); + if (outcome.kind !== "caplets_list") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host Caplets returned an invalid outcome.", + ); + } + return c.json({ caplets: outcome.caplets }); + } catch (error) { + return currentHostErrorResponse(error); + } }); - app.get(paths.dashboardCatalogSearch, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardCatalogSearch, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; try { const query = new URL(c.req.url).searchParams; - return c.json( - dashboardCatalogSearch({ + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { + kind: "catalog_search", source: requiredQueryParam(query, "source"), query: query.get("q") ?? undefined, limit: numberQueryParam(query.get("limit")), - }), + }, ); + if (outcome.kind !== "catalog_search") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host catalog search returned an invalid outcome.", + ); + } + return c.json({ entries: outcome.entries }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); - app.get(paths.dashboardCatalogDetail, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardCatalogDetail, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; try { const query = new URL(c.req.url).searchParams; - return c.json( - dashboardCatalogDetail({ + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { + kind: "catalog_detail", source: requiredQueryParam(query, "source"), capletId: requiredQueryParam(query, "id"), - }), + }, ); + if (outcome.kind !== "catalog_detail") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host catalog detail returned an invalid outcome.", + ); + } + return c.json({ + entry: outcome.entry, + setupActions: outcome.setupActions, + projectScopedInstallAvailable: outcome.projectScopedInstallAvailable, + }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); - app.get(paths.dashboardCatalogUpdates, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardCatalogUpdates, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - return c.json(dashboardUpdateReadiness({ context: dashboardCatalogContext() })); + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "catalog_updates" }, + ); + if (outcome.kind !== "catalog_updates") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host catalog updates returned an invalid outcome.", + ); + } + return c.json({ updates: outcome.updates }); + } catch (error) { + return currentHostErrorResponse(error); + } }); app.post(paths.dashboardCatalogInstall, async (c) => { - const session = validateDashboardSession(c.req.header("cookie"), { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); if (!session.ok) return session.response; try { const parsed = await parseJsonObject(c.req.json(), "Dashboard catalog install request"); - const result = await dashboardInstallCatalogCaplet({ - source: stringField(parsed, "source"), - capletId: stringField(parsed, "capletId"), - force: optionalBooleanField(parsed, "force"), - context: dashboardCatalogContext(), - }); - for (const installed of result.installed) { - dashboardActivityLog.append({ - actorClientId: session.session.operatorClientId, - action: "catalog_installed", - target: { type: "catalog", id: installed.id }, - metadata: { status: installed.status ?? null, kind: installed.kind }, - }); + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { + kind: "catalog_install", + source: stringField(parsed, "source"), + capletIds: [stringField(parsed, "capletId")], + force: optionalBooleanField(parsed, "force"), + disableCatalogIndexing: true, + }, + ); + if (outcome.kind !== "catalog_install") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host catalog install returned an invalid outcome.", + ); } - return c.json(result); + return c.json({ installed: outcome.installed, setupActions: outcome.setupActions }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); app.post(paths.dashboardCatalogUpdate, async (c) => { - const session = validateDashboardSession(c.req.header("cookie"), { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); if (!session.ok) return session.response; try { const parsed = await parseJsonObject(c.req.json(), "Dashboard catalog update request"); - const result = await dashboardUpdateCatalogCaplet({ - capletId: stringField(parsed, "capletId"), - force: optionalBooleanField(parsed, "force"), - allowRiskIncrease: optionalBooleanField(parsed, "acknowledgeRiskIncrease") ?? false, - context: dashboardCatalogContext(), - }); - for (const installed of result.installed) { - dashboardActivityLog.append({ - actorClientId: session.session.operatorClientId, - action: "catalog_updated", - target: { type: "catalog", id: installed.id }, - metadata: { - status: installed.status ?? null, - riskAcknowledged: optionalBooleanField(parsed, "acknowledgeRiskIncrease") ?? false, - }, - }); + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { + kind: "catalog_update", + capletIds: [stringField(parsed, "capletId")], + force: optionalBooleanField(parsed, "force"), + allowRiskIncrease: optionalBooleanField(parsed, "acknowledgeRiskIncrease") ?? false, + disableCatalogIndexing: true, + }, + ); + if (outcome.kind !== "catalog_update") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host catalog update returned an invalid outcome.", + ); } - return c.json(result); + return c.json({ installed: outcome.installed, setupActions: outcome.setupActions }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); - app.get(paths.dashboardAccessClients, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardAccessClients, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - return c.json({ clients: remoteCredentialStore?.listClients() ?? [] }); + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "clients_list" }, + ); + if (outcome.kind !== "clients_list") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host clients returned an invalid outcome.", + ); + } + return c.json({ clients: outcome.clients }); + } catch (error) { + return currentHostErrorResponse(error); + } }); - app.get(paths.dashboardAccessPendingLogins, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardAccessPendingLogins, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - return c.json({ - pendingLogins: (remoteCredentialStore?.listPendingLogins() ?? []).filter( - (login) => login.status === "pending" || login.status === "approved", - ), - }); + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "pending_logins_list" }, + ); + if (outcome.kind !== "pending_logins_list") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host pending logins returned an invalid outcome.", + ); + } + return c.json({ pendingLogins: outcome.pendingLogins }); + } catch (error) { + return currentHostErrorResponse(error); + } }); app.post(routePath(paths.dashboardAccessPendingLogins, ":flowId/approve"), async (c) => { - if (!remoteCredentialStore) return c.json({ error: "dashboard_auth_unavailable" }, 404); - const session = validateDashboardSession(c.req.header("cookie"), { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); @@ -469,246 +596,277 @@ export function createHttpServeApp( c.req.json(), "Dashboard pending login approval request", ); - const pendingLogin = remoteCredentialStore.approvePendingLoginFlow({ - flowId: requiredRouteParam(c.req.param("flowId")), - grantedRole: optionalRemoteClientRole(parsed, "grantedRole"), - }); - dashboardActivityLog.append({ - actorClientId: session.session.operatorClientId, - action: "pending_login_approved", - target: { - type: "pending_login", - id: pendingLogin.flowId, - label: pendingLogin.clientLabel, - }, - metadata: { - requestedRole: pendingLogin.requestedRole, - grantedRole: pendingLogin.grantedRole ?? pendingLogin.requestedRole, + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { + kind: "pending_login_approve", + flowId: requiredRouteParam(c.req.param("flowId")), + grantedRole: optionalRemoteClientRole(parsed, "grantedRole"), }, - }); - return c.json({ pendingLogin }); + ); + if (outcome.kind !== "pending_login_approve") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host approval returned an invalid outcome.", + ); + } + if ("status" in outcome) return c.json({ error: "dashboard_auth_unavailable" }, 404); + return c.json({ pendingLogin: outcome.pendingLogin }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); - app.post(routePath(paths.dashboardAccessPendingLogins, ":flowId/deny"), (c) => { - if (!remoteCredentialStore) return c.json({ error: "dashboard_auth_unavailable" }, 404); - const session = validateDashboardSession(c.req.header("cookie"), { + app.post(routePath(paths.dashboardAccessPendingLogins, ":flowId/deny"), async (c) => { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); if (!session.ok) return session.response; try { - const pendingLogin = remoteCredentialStore.denyPendingLoginFlow({ - flowId: requiredRouteParam(c.req.param("flowId")), - }); - dashboardActivityLog.append({ - actorClientId: session.session.operatorClientId, - action: "pending_login_denied", - target: { - type: "pending_login", - id: pendingLogin.flowId, - label: pendingLogin.clientLabel, - }, - metadata: { requestedRole: pendingLogin.requestedRole }, - }); - return c.json({ pendingLogin }); + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "pending_login_deny", flowId: requiredRouteParam(c.req.param("flowId")) }, + ); + if (outcome.kind !== "pending_login_deny") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host denial returned an invalid outcome.", + ); + } + if ("status" in outcome) return c.json({ error: "dashboard_auth_unavailable" }, 404); + return c.json({ pendingLogin: outcome.pendingLogin }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); - app.post(routePath(paths.dashboardAccessClients, ":clientId/revoke"), (c) => { - if (!remoteCredentialStore) return c.json({ error: "dashboard_auth_unavailable" }, 404); - const session = validateDashboardSession(c.req.header("cookie"), { + app.post(routePath(paths.dashboardAccessClients, ":clientId/revoke"), async (c) => { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); if (!session.ok) return session.response; - const clientId = requiredRouteParam(c.req.param("clientId")); - const client = remoteCredentialStore - .listClients() - .find((candidate) => candidate.clientId === clientId); - const revoked = remoteCredentialStore.revokeClient(clientId); - if (revoked) { - dashboardActivityLog.append({ - actorClientId: session.session.operatorClientId, - action: "remote_client_revoked", - target: { - type: "remote_client", - id: clientId, - ...(client ? { label: client.clientLabel } : {}), - }, - metadata: { role: client?.role ?? null }, + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "client_revoke", clientId: requiredRouteParam(c.req.param("clientId")) }, + ); + if (outcome.kind !== "client_revoke") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host revocation returned an invalid outcome.", + ); + } + if ("status" in outcome) return c.json({ error: "dashboard_auth_unavailable" }, 404); + if (outcome.sessionEnded) { + dashboardSessionStore.delete(c.req.header("cookie")); + c.header("set-cookie", expiredDashboardSessionCookie(paths.dashboard)); + } + return c.json({ + revoked: outcome.revoked, + clientId: outcome.clientId, + sessionEnded: outcome.sessionEnded, }); + } catch (error) { + return currentHostErrorResponse(error); } - const sessionEnded = clientId === session.session.operatorClientId && revoked; - if (sessionEnded) c.header("set-cookie", expiredDashboardSessionCookie(paths.dashboard)); - return c.json({ revoked, clientId, sessionEnded }); }); app.post(routePath(paths.dashboardAccessClients, ":clientId/role"), async (c) => { - if (!remoteCredentialStore) return c.json({ error: "dashboard_auth_unavailable" }, 404); - const session = validateDashboardSession(c.req.header("cookie"), { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); if (!session.ok) return session.response; try { - const clientId = requiredRouteParam(c.req.param("clientId")); const parsed = await parseJsonObject(c.req.json(), "Dashboard remote client role request"); - const role = requiredRemoteClientRole(parsed, "role"); - const before = remoteCredentialStore - .listClients() - .find((candidate) => candidate.clientId === clientId); - const client = remoteCredentialStore.changeClientRole(clientId, role); - if (!client) return c.json({ error: "client_not_found" }, 404); - dashboardActivityLog.append({ - actorClientId: session.session.operatorClientId, - action: "remote_client_role_changed", - target: { type: "remote_client", id: client.clientId, label: client.clientLabel }, - metadata: roleChangeMetadata(before?.role ?? client.role, client.role), - }); - const sessionEnded = - clientId === session.session.operatorClientId && client.role !== "operator"; - if (sessionEnded) c.header("set-cookie", expiredDashboardSessionCookie(paths.dashboard)); - return c.json({ client, sessionEnded }); + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { + kind: "client_change_role", + clientId: requiredRouteParam(c.req.param("clientId")), + role: requiredRemoteClientRole(parsed, "role"), + }, + ); + if (outcome.kind !== "client_change_role") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host role change returned an invalid outcome.", + ); + } + if (outcome.status === "credential_store_unavailable") { + return c.json({ error: "dashboard_auth_unavailable" }, 404); + } + if (outcome.status === "not_found") return c.json({ error: "client_not_found" }, 404); + if (outcome.sessionEnded) { + dashboardSessionStore.delete(c.req.header("cookie")); + c.header("set-cookie", expiredDashboardSessionCookie(paths.dashboard)); + } + return c.json({ client: outcome.client, sessionEnded: outcome.sessionEnded }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); - app.get(paths.dashboardActivity, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardActivity, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - const query = new URL(c.req.url).searchParams; - return c.json( - dashboardActivityLog.list({ - limit: numberQueryParam(query.get("limit")), - after: query.get("after") ?? undefined, - action: optionalActivityAction(query.get("action") ?? undefined), - }), - ); + try { + const query = new URL(c.req.url).searchParams; + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { + kind: "activity_list", + limit: numberQueryParam(query.get("limit")), + after: query.get("after") ?? undefined, + action: optionalActivityAction(query.get("action") ?? undefined), + }, + ); + if (outcome.kind !== "activity_list") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host activity returned an invalid outcome.", + ); + } + return c.json(outcome.activity); + } catch (error) { + return currentHostErrorResponse(error); + } }); - app.get(paths.dashboardVault, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardVault, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - const vault = dashboardVaultStore(); - return c.json({ - values: vault.listValues(), - grants: vault.listAccess().map(redactedVaultGrant), - }); + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "vault_list" }, + ); + if (outcome.kind !== "vault_list") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host Vault list returned an invalid outcome.", + ); + } + return c.json({ values: outcome.values, grants: outcome.grants }); + } catch (error) { + return currentHostErrorResponse(error); + } }); app.post(paths.dashboardVaultSet, async (c) => { - const session = validateDashboardSession(c.req.header("cookie"), { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); if (!session.ok) return session.response; try { const parsed = await parseJsonObject(c.req.json(), "Dashboard Vault set request"); - const key = stringField(parsed, "key"); - const status = dashboardVaultStore().set(key, stringField(parsed, "value"), { - force: optionalBooleanField(parsed, "force") ?? false, - }); - dashboardActivityLog.append({ - actorClientId: session.session.operatorClientId, - action: "vault_set", - target: { type: "vault", id: status.key }, - metadata: { bytesWritten: status.valueBytes ?? null }, - }); - return c.json({ status }); + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { + kind: "vault_set", + name: stringField(parsed, "key"), + value: opaqueStringField(parsed, "value"), + force: optionalBooleanField(parsed, "force"), + }, + ); + if (outcome.kind !== "vault_set") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host Vault set returned an invalid outcome.", + ); + } + return c.json({ status: outcome.status }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); - app.post(routePath(paths.dashboardVaultValues, ":key/delete"), (c) => { - const session = validateDashboardSession(c.req.header("cookie"), { + app.post(routePath(paths.dashboardVaultValues, ":key/delete"), async (c) => { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); if (!session.ok) return session.response; try { - const deleted = dashboardVaultStore().delete(requiredRouteParam(c.req.param("key"))); - dashboardActivityLog.append({ - actorClientId: session.session.operatorClientId, - action: "vault_deleted", - target: { type: "vault", id: deleted.key }, - metadata: { deleted: deleted.deleted, grantsRetained: deleted.grantsRetained }, - }); - return c.json({ deleted }); + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "vault_delete", name: requiredRouteParam(c.req.param("key")) }, + ); + if (outcome.kind !== "vault_delete") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host Vault delete returned an invalid outcome.", + ); + } + return c.json({ deleted: outcome.deleted }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); app.post(paths.dashboardVaultGrant, async (c) => { - const session = validateDashboardSession(c.req.header("cookie"), { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); if (!session.ok) return session.response; try { const parsed = await parseJsonObject(c.req.json(), "Dashboard Vault grant request"); - const grant = dashboardVaultStore().grantAccess({ - storedKey: stringField(parsed, "storedKey"), - referenceName: stringField(parsed, "referenceName"), - capletId: stringField(parsed, "capletId"), - origin: vaultOriginField(parsed), - }); - dashboardActivityLog.append({ - actorClientId: session.session.operatorClientId, - action: "vault_grant_added", - target: { type: "vault", id: grant.storedKey }, - metadata: { - referenceName: grant.referenceName, - capletId: grant.capletId, - originKind: grant.origin.kind, + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { + kind: "vault_access_grant", + storedKey: stringField(parsed, "storedKey"), + referenceName: stringField(parsed, "referenceName"), + capletId: stringField(parsed, "capletId"), }, - }); - return c.json({ grant: redactedVaultGrant(grant) }); + ); + if (outcome.kind !== "vault_access_grant") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host Vault grant returned an invalid outcome.", + ); + } + return c.json({ grant: outcome.grant }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); app.post(paths.dashboardVaultRevoke, async (c) => { - const session = validateDashboardSession(c.req.header("cookie"), { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); if (!session.ok) return session.response; try { const parsed = await parseJsonObject(c.req.json(), "Dashboard Vault revoke request"); - const revoked = dashboardVaultStore().revokeAccess({ - storedKey: stringField(parsed, "storedKey"), - ...optionalStringObjectProp(parsed, "referenceName"), - ...optionalStringObjectProp(parsed, "capletId"), - }); - for (const grant of revoked) { - dashboardActivityLog.append({ - actorClientId: session.session.operatorClientId, - action: "vault_grant_revoked", - target: { type: "vault", id: grant.storedKey }, - metadata: { - referenceName: grant.referenceName, - capletId: grant.capletId, - originKind: grant.origin.kind, - }, - }); + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { + kind: "vault_access_revoke", + storedKey: stringField(parsed, "storedKey"), + referenceName: optionalStringField(parsed, "referenceName"), + capletId: optionalStringField(parsed, "capletId"), + }, + ); + if (outcome.kind !== "vault_access_revoke") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host Vault revoke returned an invalid outcome.", + ); } - return c.json({ revoked: revoked.map(redactedVaultGrant) }); + return c.json({ revoked: outcome.revoked }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); app.post(paths.dashboardVaultReveal, async (c) => { - const session = validateDashboardSession(c.req.header("cookie"), { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); @@ -719,7 +877,7 @@ export function createHttpServeApp( if (stringField(parsed, "confirmation") !== `reveal ${key}`) { throw new CapletsError("REQUEST_INVALID", "Vault reveal confirmation is invalid."); } - const value = dashboardVaultStore().resolveValue(key); + const value = vaultStoreForAuthDir(io.control?.authDir).resolveValue(key); dashboardActivityLog.append({ actorClientId: session.session.operatorClientId, action: "vault_value_revealed", @@ -728,98 +886,153 @@ export function createHttpServeApp( }); return c.json({ key, value }, 200, { "cache-control": "no-store" }); } catch (error) { - return remoteCredentialErrorResponse(error); + return currentHostErrorResponse(error); } }); - app.get(paths.dashboardRuntime, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardRuntime, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - const baseUrl = remoteCredentialHostUrl(c.req.url, paths.base, options, (name) => - c.req.header(name), - ); - return c.json({ - runtime: { - status: "ok", - version: packageJsonVersion, - bind: `${options.host}:${options.port}`, - baseUrl, - publicOrigin: options.publicOrigin ?? null, - }, - daemon: { restartAvailable: false, stopAvailable: false, uninstallAvailable: false }, - }); + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { + kind: "runtime", + baseUrl: remoteCredentialHostUrl(c.req.url, paths.base, options, (name) => + c.req.header(name), + ), + bind: `${options.host}:${options.port}`, + publicOrigin: options.publicOrigin ?? null, + }, + ); + if (outcome.kind !== "runtime") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host runtime returned an invalid outcome.", + ); + } + return c.json({ runtime: outcome.runtime, daemon: outcome.daemon }); + } catch (error) { + return currentHostErrorResponse(error); + } }); - app.post(paths.dashboardRuntimeRestart, (c) => { - const session = validateDashboardSession(c.req.header("cookie"), { + app.post(paths.dashboardRuntimeRestart, async (c) => { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); if (!session.ok) return session.response; - return c.json({ restartAvailable: false, reason: "daemon_manager_unavailable" }, 409); + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "runtime_restart" }, + ); + if (outcome.kind !== "runtime_restart") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host restart returned an invalid outcome.", + ); + } + return c.json({ restartAvailable: outcome.restartAvailable, reason: outcome.reason }, 409); + } catch (error) { + return currentHostErrorResponse(error); + } }); - app.get(paths.dashboardLogs, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardLogs, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - const limit = numberQueryParam(new URL(c.req.url).searchParams.get("limit")) ?? 100; - return c.json({ entries: [], limit: Math.min(500, Math.max(1, limit)), truncated: false }); + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "logs", limit: numberQueryParam(new URL(c.req.url).searchParams.get("limit")) }, + ); + if (outcome.kind !== "logs") { + throw new CapletsError("INTERNAL_ERROR", "Current Host logs returned an invalid outcome."); + } + return c.json({ + entries: outcome.entries, + limit: outcome.limit, + truncated: outcome.truncated, + }); + } catch (error) { + return currentHostErrorResponse(error); + } }); - app.get(paths.dashboardDiagnostics, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardDiagnostics, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - return c.json({ - status: "ok", - diagnostics: [], - checks: [ - { id: "runtime", status: "ok" }, - { id: "dashboard", status: "ok" }, - ], - }); + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "diagnostics" }, + ); + if (outcome.kind !== "diagnostics") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host diagnostics returned an invalid outcome.", + ); + } + return c.json({ + status: outcome.status, + diagnostics: outcome.diagnostics, + checks: outcome.checks, + }); + } catch (error) { + return currentHostErrorResponse(error); + } }); - app.get(paths.dashboardEvents, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardEvents, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - const event = { - type: "runtime_health", - runtime: { status: "ok", version: packageJsonVersion }, - projectBinding: { state: "disconnected" }, - }; - return new Response( - `id: ${Date.now()}\nevent: runtime_health\ndata: ${JSON.stringify(event)}\n\n`, - { - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "runtime_event" }, + ); + if (outcome.kind !== "runtime_event") { + throw new CapletsError("INTERNAL_ERROR", "Current Host event returned an invalid outcome."); + } + return new Response( + `id: ${Date.now()}\nevent: runtime_health\ndata: ${JSON.stringify(outcome.event)}\n\n`, + { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, }, - }, - ); + ); + } catch (error) { + return currentHostErrorResponse(error); + } }); - app.get(paths.dashboardProjectBinding, (c) => { - const session = validateDashboardSession(c.req.header("cookie")); + app.get(paths.dashboardProjectBinding, async (c) => { + const session = dashboardSessionForRequest(c); if (!session.ok) return session.response; - return c.json({ - projectBinding: { - state: "disconnected", - affectedCaplets: [], - actions: [ - { - id: "attach-project", - label: "Attach project from a client", - enabled: false, - reason: "Project Binding sessions are started by Access Clients.", - }, - ], - }, - }); + try { + const outcome = await currentHostOperations.execute( + dashboardPrincipalForSession(c, session.session), + { kind: "project_binding" }, + ); + if (outcome.kind !== "project_binding") { + throw new CapletsError( + "INTERNAL_ERROR", + "Current Host Project Binding returned an invalid outcome.", + ); + } + return c.json({ projectBinding: outcome.projectBinding }); + } catch (error) { + return currentHostErrorResponse(error); + } }); app.post(paths.dashboardLogout, (c) => { - const session = validateDashboardSession(c.req.header("cookie"), { + const session = dashboardSessionForRequest(c, { requireCsrf: true, csrfToken: c.req.header("x-caplets-csrf"), }); @@ -958,16 +1171,10 @@ export function createHttpServeApp( } }); - app.delete(paths.remoteClient, protectedRouteAuth, (c) => { + app.delete(paths.remoteClient, authenticatedClientRouteAuth, (c) => { + const client = authenticatedRemoteClients.get(c.req.raw); + if (!client) return c.text("Unauthorized", 401); try { - const client = validatedRemoteClient( - c.req.header("authorization") ?? "", - remoteCredentialStore, - c.req.url, - paths.base, - options, - (name) => c.req.header(name), - ); return c.json({ revoked: remoteCredentialStore.revokeClient(client.clientId), clientId: client.clientId, @@ -978,7 +1185,7 @@ export function createHttpServeApp( }); } - app.all(paths.mcp, protectedRouteAuth, async (c) => { + app.all(paths.mcp, accessRouteAuth, async (c) => { const sessionId = c.req.header("mcp-session-id"); if (sessionId) { const existing = sessions.get(sessionId); @@ -1025,7 +1232,7 @@ export function createHttpServeApp( if (exposeAttach) { if (io.attachSessionFactory) { - app.post(paths.attachSessions, attachHostProtection, protectedRouteAuth, async (c) => { + app.post(paths.attachSessions, attachHostProtection, accessRouteAuth, async (c) => { try { const parsed = await parseJsonObject(c.req.json(), "Attach session request"); const metadata = parseAttachSessionMetadata(parsed, { @@ -1048,7 +1255,7 @@ export function createHttpServeApp( app.delete( routePath(paths.attachSessions, ":sessionId"), attachHostProtection, - protectedRouteAuth, + accessRouteAuth, async (c) => { const sessionId = c.req.param("sessionId"); if (!sessionId) { @@ -1062,7 +1269,7 @@ export function createHttpServeApp( ); } - app.get(paths.attachManifest, attachHostProtection, protectedRouteAuth, async (c) => { + app.get(paths.attachManifest, attachHostProtection, accessRouteAuth, async (c) => { try { const attachSessionId = c.req.header(CAPLETS_ATTACH_SESSION_HEADER); const attachSession = attachSessionId @@ -1079,7 +1286,7 @@ export function createHttpServeApp( } }); - app.get(paths.attachEvents, attachHostProtection, protectedRouteAuth, async (c) => { + app.get(paths.attachEvents, attachHostProtection, accessRouteAuth, async (c) => { try { const attachSessionId = c.req.header(CAPLETS_ATTACH_SESSION_HEADER); const attachSession = attachSessionId @@ -1098,7 +1305,7 @@ export function createHttpServeApp( } }); - app.post(paths.attachInvoke, attachHostProtection, protectedRouteAuth, async (c) => { + app.post(paths.attachInvoke, attachHostProtection, accessRouteAuth, async (c) => { try { const request = await parseAttachInvokeRequest(c.req.json()); const attachSessionId = c.req.header(CAPLETS_ATTACH_SESSION_HEADER); @@ -1188,14 +1395,10 @@ export function createHttpServeApp( } } - app.post(paths.control, operatorRouteAuth, async (c) => { + app.post(paths.control, currentHostDevelopmentGuard(options), operatorRouteAuth, async (c) => { let request: RemoteCliRequest; try { - const parsed = await c.req.json(); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new CapletsError("REQUEST_INVALID", "Control request JSON must be an object"); - } - request = parsed as RemoteCliRequest; + request = parseRemoteCliRequest(await c.req.json()); } catch (error) { const requestError = error instanceof CapletsError @@ -1204,26 +1407,27 @@ export function createHttpServeApp( const safe = toSafeError(requestError, "REQUEST_INVALID"); return c.json({ ok: false, error: { code: safe.code, message: safe.message } }); } + const context = controlContext( + io, + writeErr, + authFlowStore, + c.req.url, + paths.control, + options.publicOrigin, + options.trustProxy, + (name) => c.req.header(name), + ); return c.json( - await dispatchRemoteCliRequest( - request, - controlContext( - io, - writeErr, - authFlowStore, - c.req.url, - paths.control, - options.publicOrigin, - options.trustProxy, - (name) => c.req.header(name), - ), - ), + await dispatchRemoteCliRequest(request, context, { + operations: currentHostOperations, + principal: controlOperatorPrincipal(c), + }), ); }); app.get( routePath(paths.projectBindings, "connect"), - protectedRouteAuth, + accessRouteAuth, async (c, next) => { if (c.req.header("upgrade")?.toLowerCase() !== "websocket") { return c.json({ error: "websocket_upgrade_required" }, 426); @@ -1286,14 +1490,14 @@ export function createHttpServeApp( }), ); - app.get(routePath(paths.projectBindings, ":bindingId/status"), protectedRouteAuth, (c) => + app.get(routePath(paths.projectBindings, ":bindingId/status"), accessRouteAuth, (c) => projectBindingStatusResponse( requiredRouteParam(c.req.param("bindingId")), projectBindingOwnerKey(c), ), ); - app.post(routePath(paths.projectBindings, "sessions"), protectedRouteAuth, async (c) => { + app.post(routePath(paths.projectBindings, "sessions"), accessRouteAuth, async (c) => { try { const parsed = await parseJsonObject(c.req.json(), "Project Binding session request"); const projectRoot = stringField(parsed, "projectRoot"); @@ -1339,7 +1543,7 @@ export function createHttpServeApp( } }); - app.get(routePath(paths.projectBindings, ":bindingId/session"), protectedRouteAuth, (c) => { + app.get(routePath(paths.projectBindings, ":bindingId/session"), accessRouteAuth, (c) => { const record = projectBindingRecordFor( requiredRouteParam(c.req.param("bindingId")), projectBindingOwnerKey(c), @@ -1352,51 +1556,43 @@ export function createHttpServeApp( }); }); - app.post( - routePath(paths.projectBindings, ":bindingId/heartbeat"), - protectedRouteAuth, - async (c) => { - try { - const bindingId = requiredRouteParam(c.req.param("bindingId")); - const ownerKey = projectBindingOwnerKey(c); - let record = projectBindingRecordFor(bindingId, ownerKey); - if (!record) return c.json({ ok: false, error: { code: "REQUEST_INVALID" } }, 404); - const parsed = await parseJsonObject(c.req.json(), "Project Binding heartbeat request"); - const sessionId = stringField(parsed, "sessionId"); - record = projectBindingRecordFor(bindingId, ownerKey); - if (!record) return c.json({ ok: false, error: { code: "REQUEST_INVALID" } }, 404); - if (sessionId !== record.sessionId) { - return c.json({ ok: false, error: { code: "REQUEST_INVALID" } }, 403); - } - await updateProjectBindingHeartbeat(record, { - type: "heartbeat", - bindingId, - sessionId, - state: projectBindingStateField(parsed, "state", record.state), - syncState: projectBindingSyncStateField(parsed, "syncState", record.syncState), - }); - return c.json({ ok: true, binding: projectBindingResponse(record) }); - } catch (error) { - const safe = toSafeError( - error, - error instanceof CapletsError ? error.code : "REQUEST_INVALID", - ); - return c.json({ ok: false, error: safe }, error instanceof CapletsError ? 400 : 500); - } - }, - ); - - app.delete( - routePath(paths.projectBindings, ":bindingId/session"), - protectedRouteAuth, - async (c) => { + app.post(routePath(paths.projectBindings, ":bindingId/heartbeat"), accessRouteAuth, async (c) => { + try { const bindingId = requiredRouteParam(c.req.param("bindingId")); - const record = projectBindingRecordFor(bindingId, projectBindingOwnerKey(c)); + const ownerKey = projectBindingOwnerKey(c); + let record = projectBindingRecordFor(bindingId, ownerKey); + if (!record) return c.json({ ok: false, error: { code: "REQUEST_INVALID" } }, 404); + const parsed = await parseJsonObject(c.req.json(), "Project Binding heartbeat request"); + const sessionId = stringField(parsed, "sessionId"); + record = projectBindingRecordFor(bindingId, ownerKey); if (!record) return c.json({ ok: false, error: { code: "REQUEST_INVALID" } }, 404); - await endProjectBindingRecord(record); + if (sessionId !== record.sessionId) { + return c.json({ ok: false, error: { code: "REQUEST_INVALID" } }, 403); + } + await updateProjectBindingHeartbeat(record, { + type: "heartbeat", + bindingId, + sessionId, + state: projectBindingStateField(parsed, "state", record.state), + syncState: projectBindingSyncStateField(parsed, "syncState", record.syncState), + }); return c.json({ ok: true, binding: projectBindingResponse(record) }); - }, - ); + } catch (error) { + const safe = toSafeError( + error, + error instanceof CapletsError ? error.code : "REQUEST_INVALID", + ); + return c.json({ ok: false, error: safe }, error instanceof CapletsError ? 400 : 500); + } + }); + + app.delete(routePath(paths.projectBindings, ":bindingId/session"), accessRouteAuth, async (c) => { + const bindingId = requiredRouteParam(c.req.param("bindingId")); + const record = projectBindingRecordFor(bindingId, projectBindingOwnerKey(c)); + if (!record) return c.json({ ok: false, error: { code: "REQUEST_INVALID" } }, 404); + await endProjectBindingRecord(record); + return c.json({ ok: true, binding: projectBindingResponse(record) }); + }); function projectBindingSocketRecordForRequest( c: Parameters[0], @@ -1486,14 +1682,11 @@ export function createHttpServeApp( function projectBindingOwnerKey(c: Parameters[0]): string { if (!remoteCredentialStore) return "development_unauthenticated"; - return validatedRemoteClient( - authorizationHeaderForRequest(c), - remoteCredentialStore, - c.req.url, - paths.base, - options, - (name) => c.req.header(name), - ).clientId; + const client = authenticatedRemoteClients.get(c.req.raw); + if (!client) { + throw new CapletsError("AUTH_FAILED", "Remote client credential is required."); + } + return client.clientId; } function projectBindingRecordFor( @@ -1566,30 +1759,74 @@ export function createHttpServeApp( return value as ProjectBindingState; } - function dashboardCatalogContext() { + function dashboardSessionForRequest( + c: Parameters[0], + csrf: { requireCsrf?: boolean; csrfToken?: string | undefined } = {}, + ): { ok: true; session: DashboardSessionView } | { ok: false; response: Response } { + return validateDashboardSession(c.req.header("cookie"), csrf, c.req.url, (name) => + c.req.header(name), + ); + } + + function dashboardPrincipalForSession( + c: Parameters[0], + session: DashboardSessionView, + ): CurrentHostOperatorPrincipal { + const hostUrl = remoteCredentialHostUrl(c.req.url, paths.base, options, (name) => + c.req.header(name), + ); + if (session.operatorClientId === "development_unauthenticated") { + return trustedDevelopmentOperatorPrincipal(hostUrl); + } return { - control: io.control - ? { - ...io.control, - projectCapletsRoot: io.control.projectCapletsRoot, - globalCapletsRoot: io.control.globalCapletsRoot, - globalLockfilePath: io.control.globalLockfilePath, - } - : undefined, + clientId: session.operatorClientId, + hostUrl, + role: "operator", }; } - function dashboardVaultStore() { - return vaultStoreForAuthDir(io.control?.authDir); + function controlOperatorPrincipal( + c: Parameters[0], + ): CurrentHostOperatorPrincipal { + const client = authenticatedRemoteClients.get(c.req.raw); + if (client) { + if (client.role !== "operator") { + throw new CapletsError( + "AUTH_FAILED", + "Current Host administration requires an Operator principal.", + ); + } + return { + clientId: client.clientId, + clientLabel: client.clientLabel, + hostUrl: client.hostUrl, + role: "operator", + }; + } + if ( + options.auth.type === "development_unauthenticated" && + isVerifiedLoopbackDevelopmentRequest(options, c.req.url, (name) => c.req.header(name)) + ) { + return trustedDevelopmentOperatorPrincipal( + remoteCredentialHostUrl(c.req.url, paths.base, options, (name) => c.req.header(name)), + ); + } + throw new CapletsError( + "AUTH_FAILED", + "Current Host administration requires an Operator principal.", + ); } function validateDashboardSession( cookieHeader: string | undefined, - csrf: { requireCsrf?: boolean; csrfToken?: string | undefined } = {}, - ): - | { ok: true; session: ReturnType } - | { ok: false; response: Response } { + csrf: { requireCsrf?: boolean; csrfToken?: string | undefined }, + requestUrl: string, + header: (name: string) => string | undefined, + ): { ok: true; session: DashboardSessionView } | { ok: false; response: Response } { if (!remoteCredentialStore && options.auth.type === "development_unauthenticated") { + if (!isVerifiedLoopbackDevelopmentRequest(options, requestUrl, header)) { + return { ok: false, response: new Response("Forbidden", { status: 403 }) }; + } if (csrf.requireCsrf && csrf.csrfToken !== "development_unauthenticated") { return { ok: false, response: new Response("Forbidden", { status: 403 }) }; } @@ -1633,52 +1870,6 @@ export function createHttpServeApp( } } - function dashboardSummary(requestUrl: string, header: (name: string) => string | undefined) { - const baseUrl = remoteCredentialHostUrl(requestUrl, paths.base, options, header); - const dashboardUrl = new URL( - paths.dashboard, - publicRequestOrigin(requestUrl, options.trustProxy, header), - ).toString(); - const pendingLogins = remoteCredentialStore?.listPendingLogins() ?? []; - const clients = remoteCredentialStore?.listClients() ?? []; - const caplets = engine.enabledServers(); - const vault = vaultStoreForAuthDir(io.control?.authDir); - const vaultValues = vault.listValues(); - return { - host: { - current: true, - baseUrl, - dashboardUrl, - version: packageJsonVersion, - roleModel: "current-host", - }, - attention: pendingLogins - .filter((login) => login.status === "pending") - .map((login) => ({ - kind: "pending-login", - severity: "warning", - label: `${login.clientLabel} is waiting for ${login.requestedRole} approval`, - href: `${paths.dashboard}#access`, - })), - sections: { - caplets: { count: caplets.length, href: `${paths.dashboard}#caplets` }, - catalog: { href: `${paths.dashboard}#catalog` }, - access: { - clients: clients.length, - pending: pendingLogins.filter((login) => login.status === "pending").length, - href: `${paths.dashboard}#access`, - }, - vault: { count: vaultValues.length, href: `${paths.dashboard}#vault` }, - projectBinding: { state: "disconnected", href: `${paths.dashboard}#project-binding` }, - runtime: { status: "ok", href: `${paths.dashboard}#runtime` }, - logs: { href: `${paths.dashboard}#logs` }, - diagnostics: { href: `${paths.dashboard}#diagnostics` }, - activity: { href: `${paths.dashboard}#activity` }, - settings: { href: `${paths.dashboard}#settings` }, - }, - }; - } - function projectBindingSyncStateField( input: Record, key: string, @@ -2428,50 +2619,6 @@ async function createHttpSession( return { server, transport }; } -function redactedVaultGrant(grant: { - storedKey: string; - referenceName: string; - capletId: string; - origin: { kind: string; path?: string | undefined }; - createdAt: string; - updatedAt: string; -}) { - return { - storedKey: grant.storedKey, - referenceName: grant.referenceName, - capletId: grant.capletId, - origin: { kind: grant.origin.kind }, - createdAt: grant.createdAt, - updatedAt: grant.updatedAt, - }; -} - -function vaultOriginField(input: Record) { - const origin = input.origin; - if (!origin || typeof origin !== "object" || Array.isArray(origin)) { - throw new CapletsError("REQUEST_INVALID", "origin is required."); - } - const record = origin as Record; - const kind = stringField(record, "kind"); - if ( - kind !== "global-config" && - kind !== "global-file" && - kind !== "project-config" && - kind !== "project-file" - ) { - throw new CapletsError("REQUEST_INVALID", "origin.kind is invalid."); - } - return { kind: kind as ConfigSourceKind, path: stringField(record, "path") }; -} - -function optionalStringObjectProp( - input: Record, - key: string, -): Record { - const value = optionalStringField(input, key); - return value === undefined ? {} : { [key]: value }; -} - function requiredQueryParam(params: URLSearchParams, key: string): string { const value = params.get(key); if (!value) throw new CapletsError("REQUEST_INVALID", `${key} is required.`); @@ -2535,7 +2682,10 @@ function routeAuth( options: HttpServeOptions, remoteCredentialStore: RemoteServerCredentialStore | undefined, basePath: string, - requiredRole?: RemoteClientRole | undefined, + requiredRole: RemoteClientRole | undefined, + retainAuthenticatedClient: + | ((request: Request, client: ValidatedRemoteClient) => void) + | undefined, ): MiddlewareHandler { if (options.auth.type === "development_unauthenticated") { return async (_c, next) => { @@ -2561,9 +2711,10 @@ function routeAuth( ), accessToken: token, }); - if (requiredRole === "operator" && client.role !== "operator") { - return c.text("Forbidden: operator role required", 403); + if (requiredRole !== undefined && client.role !== requiredRole) { + return c.text(`Forbidden: ${requiredRole} role required`, 403); } + retainAuthenticatedClient?.(c.req.raw, client); } catch { return c.text("Unauthorized", 401); } @@ -2571,22 +2722,40 @@ function routeAuth( }; } -function validatedRemoteClient( - authorizationHeader: string, - remoteCredentialStore: RemoteServerCredentialStore, - requestUrl: string, - basePath: string, +function currentHostDevelopmentGuard(options: HttpServeOptions): MiddlewareHandler { + if (options.auth.type !== "development_unauthenticated") { + return async (_c, next) => { + await next(); + }; + } + return async (c, next) => { + if (!isVerifiedLoopbackDevelopmentRequest(options, c.req.url, (name) => c.req.header(name))) { + return c.text("Forbidden", 403); + } + await next(); + }; +} + +function isVerifiedLoopbackDevelopmentRequest( options: HttpServeOptions, + requestUrl: string, header: (name: string) => string | undefined, -) { - const token = bearerToken(authorizationHeader); - if (!token) { - throw new CapletsError("AUTH_FAILED", "Remote client credential is required."); +): boolean { + if (!options.loopback || !isLoopbackHost(options.host)) return false; + let requestHost: string; + try { + requestHost = new URL(requestUrl).hostname; + } catch { + return false; + } + if (!isLoopbackHost(requestHost)) return false; + const host = header("host"); + if (!host) return true; + try { + return isLoopbackHost(new URL(`http://${host}`).hostname); + } catch { + return false; } - return remoteCredentialStore.validateAccessToken({ - hostUrl: remoteCredentialHostUrl(requestUrl, basePath, options, header), - accessToken: token, - }); } function remoteCredentialStoreForOptions( @@ -2640,10 +2809,21 @@ async function parseJsonObject( } catch (error) { throw new CapletsError("REQUEST_INVALID", `${label} body must be valid JSON.`, error); } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (!isRecord(parsed)) { throw new CapletsError("REQUEST_INVALID", `${label} JSON must be an object.`); } - return parsed as Record; + return parsed; +} + +function parseRemoteCliRequest(value: unknown): RemoteCliRequest { + if (!isRecord(value) || typeof value.command !== "string" || !isRecord(value.arguments)) { + throw new CapletsError("REQUEST_INVALID", "Control request JSON must be an object."); + } + return { command: value.command, arguments: value.arguments }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } function stringField(input: Record, key: string): string { @@ -2653,6 +2833,13 @@ function stringField(input: Record, key: string): string { } return value.trim(); } +function opaqueStringField(input: Record, key: string): string { + const value = input[key]; + if (typeof value !== "string" || value.length === 0) { + throw new CapletsError("REQUEST_INVALID", `${key} must be a non-empty string.`); + } + return value; +} function optionalStringField(input: Record, key: string): string | undefined { const value = input[key]; @@ -2724,6 +2911,20 @@ function remoteCredentialErrorResponse(error: unknown): Response { return Response.json({ ok: false, error: safe }, { status: httpStatusForSafeError(safe.code) }); } +function currentHostErrorResponse(error: unknown): Response { + const safe = toCurrentHostSafeError(error); + return Response.json( + { + ok: false, + error: { + code: safe.code, + message: safe.message, + }, + }, + { status: httpStatusForSafeError(safe.code) }, + ); +} + function httpStatusForSafeError(code: CapletsErrorCode): number { if (code === "REQUEST_INVALID" || code === "CONFIG_INVALID") return 400; if (code === "CONFIG_NOT_FOUND" || code === "SERVER_NOT_FOUND") return 404; diff --git a/packages/core/test/current-host-administration.test.ts b/packages/core/test/current-host-administration.test.ts new file mode 100644 index 00000000..3aa517b5 --- /dev/null +++ b/packages/core/test/current-host-administration.test.ts @@ -0,0 +1,609 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createCurrentHostOperations, + toCurrentHostSafeError, + type CurrentHostOperationOutcome, + type CurrentHostPrincipal, +} from "../src/current-host/operations"; +import { DashboardActivityLog } from "../src/dashboard/activity-log"; +import { CapletsEngine } from "../src/engine"; +import { CapletsError } from "../src/errors"; +import { RemoteServerCredentialStore } from "../src/remote/server-credential-store"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("Current Host administration operations", () => { + it("returns the safe Current Host read model, catalog projections, activity, and runtime state", async () => { + const setup = testOperations(); + try { + await setup.operations.execute(setup.principal, { + kind: "vault_set", + name: "GH_TOKEN", + value: "summary_secret", + }); + const summary: Extract = + await setup.operations.execute(setup.principal, { + kind: "summary", + baseUrl: setup.principal.hostUrl, + dashboardUrl: `${setup.principal.hostUrl}dashboard`, + dashboardPath: "/dashboard", + }); + const caplets: Extract = + await setup.operations.execute(setup.principal, { kind: "caplets_list" }); + const activity = await setup.operations.execute(setup.principal, { + kind: "activity_list", + limit: 1, + }); + const runtime: Extract = + await setup.operations.execute(setup.principal, { + kind: "runtime", + baseUrl: setup.principal.hostUrl, + bind: "127.0.0.1:5387", + publicOrigin: null, + }); + const logs = await setup.operations.execute(setup.principal, { kind: "logs", limit: 5 }); + const diagnostics = await setup.operations.execute(setup.principal, { kind: "diagnostics" }); + const event = await setup.operations.execute(setup.principal, { kind: "runtime_event" }); + const binding = await setup.operations.execute(setup.principal, { kind: "project_binding" }); + + expect(summary).toEqual( + expect.objectContaining({ + kind: "summary", + summary: expect.objectContaining({ + host: expect.objectContaining({ current: true, version: "test-version" }), + sections: expect.objectContaining({ vault: expect.objectContaining({ count: 1 }) }), + }), + }), + ); + expect(caplets).toEqual( + expect.objectContaining({ + kind: "caplets_list", + caplets: [expect.objectContaining({ id: "status" })], + }), + ); + expect(activity).toEqual( + expect.objectContaining({ + kind: "activity_list", + activity: expect.objectContaining({ + entries: [expect.objectContaining({ action: "vault_set" })], + }), + }), + ); + expect(runtime).toEqual( + expect.objectContaining({ + kind: "runtime", + runtime: expect.objectContaining({ bind: "127.0.0.1:5387" }), + }), + ); + expect(logs).toEqual({ kind: "logs", entries: [], limit: 5, truncated: false }); + expect(diagnostics).toEqual(expect.objectContaining({ kind: "diagnostics", status: "ok" })); + expect(event).toEqual(expect.objectContaining({ kind: "runtime_event" })); + expect(binding).toEqual(expect.objectContaining({ kind: "project_binding" })); + const serialized = JSON.stringify({ + summary, + caplets, + activity, + runtime, + logs, + diagnostics, + event, + binding, + }); + expect(serialized).not.toContain("summary_secret"); + expect(serialized).not.toContain(setup.configPath); + } finally { + await setup.engine.close(); + } + }); + + it("shares catalog installation and update policy with actor-attributed activity", async () => { + const setup = testOperations(); + try { + const source = catalogSource(setup.root); + const installed = await setup.operations.execute(setup.principal, { + kind: "catalog_install", + source, + capletIds: ["sample"], + disableCatalogIndexing: true, + }); + const updates = await setup.operations.execute(setup.principal, { kind: "catalog_updates" }); + const updated = await setup.operations.execute(setup.principal, { + kind: "catalog_update", + capletIds: ["sample"], + allowRiskIncrease: true, + disableCatalogIndexing: true, + }); + + expect(installed).toEqual( + expect.objectContaining({ + kind: "catalog_install", + installed: [expect.objectContaining({ id: "sample" })], + }), + ); + expect(updates).toEqual( + expect.objectContaining({ + kind: "catalog_updates", + updates: [expect.objectContaining({ id: "sample", status: "locked" })], + }), + ); + expect(updated).toEqual( + expect.objectContaining({ + kind: "catalog_update", + installed: [expect.objectContaining({ id: "sample" })], + }), + ); + const entries = setup.activity.list().entries; + expect(entries.filter((entry) => entry.action === "catalog_installed")).toEqual([ + expect.objectContaining({ + actorClientId: setup.principal.clientId, + target: { type: "catalog", id: "sample" }, + }), + ]); + expect(entries.filter((entry) => entry.action === "catalog_updated")).toEqual([ + expect.objectContaining({ + actorClientId: setup.principal.clientId, + target: { type: "catalog", id: "sample" }, + }), + ]); + expect(JSON.stringify(entries)).not.toContain(source); + } finally { + await setup.engine.close(); + } + }); + it("redacts credential assignments and filesystem paths from classified failures", () => { + const safe = toCurrentHostSafeError( + new CapletsError( + "CONFIG_NOT_FOUND", + 'Clone failed password=hunter2 "client_secret":"opaque" Authorization: Bearer transport-token /tmp/private C:\\Users\\operator\\private', + ), + ); + + expect(safe).toEqual({ + code: "CONFIG_NOT_FOUND", + message: + 'Clone failed password=[REDACTED] "client_secret":"[REDACTED]" Authorization: Bearer [REDACTED] [REDACTED] [REDACTED]', + }); + }); + + it("rejects an Access principal before reading or mutating administration state", async () => { + const setup = testOperations(); + const accessPrincipal: CurrentHostPrincipal = { + clientId: setup.principal.clientId, + hostUrl: setup.principal.hostUrl, + role: "access", + }; + try { + await expect( + setup.operations.execute(accessPrincipal, { kind: "clients_list" }), + ).rejects.toMatchObject({ code: "AUTH_FAILED" }); + expect(setup.activity.list().entries).toEqual([]); + } finally { + await setup.engine.close(); + } + }); + it("accepts canonical remote principals but rejects malformed and unminted principals before mutation", async () => { + const setup = testOperations(); + const malformedPrincipals = [ + JSON.parse( + '{"clientId":"client-not-canonical","hostUrl":"http://127.0.0.1:5387/","role":"operator"}', + ), + JSON.parse('{"clientId":"rcli_abcdefghijklmnop","hostUrl":"not-a-url","role":"operator"}'), + JSON.parse( + '{"clientId":"rcli_abcdefghijklmnop","hostUrl":"file:///tmp/caplets","role":"operator"}', + ), + JSON.parse( + '{"clientId":"rcli_abcdefghijklmnop","hostUrl":"http://127.0.0.1:5387/","role":"access"}', + ), + JSON.parse('{"hostUrl":"http://127.0.0.1:5387/","role":"operator"}'), + JSON.parse('{"clientId":"rcli_abcdefghijklmnop","role":"operator"}'), + JSON.parse( + '{"clientId":"development_unauthenticated","clientLabel":"","hostUrl":"http://127.0.0.1:5387/","role":"operator"}', + ), + ]; + const canonicalPrincipal: CurrentHostPrincipal = { + clientId: "rcli_abcdefghijklmnop", + hostUrl: "https://127.0.0.1:5387/", + role: "operator", + }; + try { + await expect( + setup.operations.execute(canonicalPrincipal, { kind: "vault_list" }), + ).resolves.toEqual({ + kind: "vault_list", + values: [], + grants: [], + }); + for (const principal of malformedPrincipals) { + await expect( + setup.operations.execute(principal, { + kind: "vault_set", + name: "GH_TOKEN", + value: "malformed_principal_secret", + }), + ).rejects.toMatchObject({ code: "AUTH_FAILED" }); + } + await expect( + setup.operations.execute(setup.principal, { kind: "vault_list" }), + ).resolves.toEqual({ + kind: "vault_list", + values: [], + grants: [], + }); + expect(setup.activity.list().entries).toEqual([]); + } finally { + await setup.engine.close(); + } + }); + + it("moves Pending Remote Login and client mutations behind actor-specific outcomes", async () => { + const setup = testOperations(); + try { + const pending = setup.store.createPendingLogin({ + hostUrl: setup.principal.hostUrl, + requestedRole: "access", + clientLabel: "Tablet", + }); + const approved = await setup.operations.execute(setup.principal, { + kind: "pending_login_approve", + flowId: pending.flowId, + grantedRole: "operator", + }); + const other = issueOperator(setup.store, "Second Operator"); + const otherRevoked = await setup.operations.execute(setup.principal, { + kind: "client_revoke", + clientId: other.clientId, + }); + const missing = await setup.operations.execute(setup.principal, { + kind: "client_change_role", + clientId: "rcli_abcdefghijklmnop", + role: "access", + }); + const selfDemoted = await setup.operations.execute(setup.principal, { + kind: "client_change_role", + clientId: setup.principal.clientId, + role: "access", + }); + + expect(approved).toEqual( + expect.objectContaining({ + kind: "pending_login_approve", + pendingLogin: expect.objectContaining({ flowId: pending.flowId, status: "approved" }), + }), + ); + expect(otherRevoked).toEqual({ + kind: "client_revoke", + revoked: true, + clientId: other.clientId, + sessionEnded: false, + }); + expect(missing).toEqual({ + kind: "client_change_role", + status: "not_found", + clientId: "rcli_abcdefghijklmnop", + sessionEnded: false, + }); + expect(selfDemoted).toEqual( + expect.objectContaining({ + kind: "client_change_role", + status: "changed", + client: expect.objectContaining({ role: "access" }), + sessionEnded: true, + }), + ); + const entries = setup.activity.list().entries; + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "pending_login_approved", + actorClientId: setup.principal.clientId, + }), + expect.objectContaining({ + action: "remote_client_revoked", + actorClientId: setup.principal.clientId, + }), + expect.objectContaining({ + action: "remote_client_role_changed", + actorClientId: setup.principal.clientId, + metadata: { fromRole: "operator", toRole: "access" }, + }), + ]), + ); + expect(JSON.stringify(entries)).not.toContain(pending.operatorCode); + expect(JSON.stringify(entries)).not.toContain(pending.pendingCompletionSecret); + } finally { + await setup.engine.close(); + } + }); + + it("keeps Vault safe, server-derived, atomic, and redacted", async () => { + const setup = testOperations(); + const secret = "vault_lifecycle_secret"; + try { + await setup.operations.execute(setup.principal, { + kind: "vault_set", + name: "GH_TOKEN", + value: secret, + }); + const status = await setup.operations.execute(setup.principal, { + kind: "vault_get", + name: "GH_TOKEN", + }); + const granted = await setup.operations.execute(setup.principal, { + kind: "vault_access_grant", + storedKey: "GH_TOKEN", + referenceName: "API_TOKEN", + capletId: "status", + }); + const listed = await setup.operations.execute(setup.principal, { kind: "vault_access_list" }); + const revoked = await setup.operations.execute(setup.principal, { + kind: "vault_access_revoke", + storedKey: "GH_TOKEN", + referenceName: "API_TOKEN", + capletId: "status", + }); + const deleted = await setup.operations.execute(setup.principal, { + kind: "vault_delete", + name: "GH_TOKEN", + }); + + expect(status).toEqual( + expect.objectContaining({ + kind: "vault_get", + status: { + key: "GH_TOKEN", + present: true, + valueBytes: secret.length, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + }), + ); + expect(granted).toEqual( + expect.objectContaining({ + kind: "vault_access_grant", + grant: expect.objectContaining({ origin: { kind: "global-config" } }), + }), + ); + expect(listed).toEqual( + expect.objectContaining({ + kind: "vault_access_list", + grants: [expect.objectContaining({ storedKey: "GH_TOKEN" })], + }), + ); + expect(revoked).toEqual( + expect.objectContaining({ + kind: "vault_access_revoke", + revoked: [expect.objectContaining({ storedKey: "GH_TOKEN" })], + }), + ); + expect(deleted).toEqual( + expect.objectContaining({ + kind: "vault_delete", + deleted: expect.objectContaining({ deleted: true }), + }), + ); + const serialized = JSON.stringify({ + status, + granted, + listed, + revoked, + deleted, + activity: setup.activity.list(), + }); + expect(serialized).not.toContain(secret); + expect(serialized).not.toContain(setup.configPath); + } finally { + await setup.engine.close(); + } + }); + + it("records one success for both halves of a successful Vault set-and-grant", async () => { + const setup = testOperations(); + try { + await setup.operations.execute(setup.principal, { + kind: "vault_set", + name: "GH_TOKEN", + value: "set_and_grant_secret", + grant: "status", + referenceName: "API_TOKEN", + }); + + const entries = setup.activity.list().entries; + expect(entries.filter((entry) => entry.action === "vault_set")).toEqual([ + expect.objectContaining({ + actorClientId: setup.principal.clientId, + outcome: "success", + }), + ]); + expect(entries.filter((entry) => entry.action === "vault_grant_added")).toEqual([ + expect.objectContaining({ + actorClientId: setup.principal.clientId, + outcome: "success", + metadata: expect.objectContaining({ referenceName: "API_TOKEN", capletId: "status" }), + }), + ]); + expect(entries).toHaveLength(2); + } finally { + await setup.engine.close(); + } + }); + + it("rolls back a failed set-and-grant and records only a redacted failure", async () => { + const setup = testOperations(); + try { + await expect( + setup.operations.execute(setup.principal, { + kind: "vault_set", + name: "GH_TOKEN", + value: "rollback_secret", + grant: "missing_caplet", + }), + ).rejects.toMatchObject({ code: "SERVER_NOT_FOUND" }); + await expect( + setup.operations.execute(setup.principal, { kind: "vault_list" }), + ).resolves.toEqual({ + kind: "vault_list", + values: [], + grants: [], + }); + const entries = setup.activity.list().entries; + expect(entries).toEqual([ + expect.objectContaining({ + action: "vault_set", + actorClientId: setup.principal.clientId, + outcome: "failure", + }), + ]); + expect(JSON.stringify(entries)).not.toContain("rollback_secret"); + } finally { + await setup.engine.close(); + } + }); + + it("rejects credential-shaped identifiers without echoing them into outcomes or activity", async () => { + const setup = testOperations(); + const credentialShapedIds = [ + "cap_remote_access_sensitive", + "cap_remote_refresh_sensitive", + "cap_pending_complete_sensitive", + "cap_login_sensitive", + "cap_pair_sensitive", + ]; + try { + for (const identifier of credentialShapedIds) { + await expect( + setup.operations.execute(setup.principal, { + kind: "client_revoke", + clientId: identifier, + }), + ).rejects.toMatchObject({ code: "REQUEST_INVALID" }); + await expect( + setup.operations.execute(setup.principal, { + kind: "client_change_role", + clientId: identifier, + role: "access", + }), + ).rejects.toMatchObject({ code: "REQUEST_INVALID" }); + await expect( + setup.operations.execute(setup.principal, { + kind: "pending_login_deny", + flowId: identifier, + }), + ).rejects.toMatchObject({ code: "REQUEST_INVALID" }); + } + const serializedActivity = JSON.stringify(setup.activity.list()); + for (const identifier of credentialShapedIds) + expect(serializedActivity).not.toContain(identifier); + } finally { + await setup.engine.close(); + } + }); +}); + +function testOperations() { + const root = tempDir("caplets-current-host-operations-"); + const userRoot = join(root, "user"); + const projectRoot = join(root, "project", ".caplets"); + const authDir = join(root, "auth"); + const globalCapletsRoot = join(root, "global-caplets"); + const globalLockfilePath = join(root, "remote-state", "caplets.lock.json"); + mkdirSync(userRoot, { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + mkdirSync(authDir, { recursive: true }); + mkdirSync(globalCapletsRoot, { recursive: true }); + const configPath = join(userRoot, "config.json"); + const projectConfigPath = join(projectRoot, "config.json"); + writeFileSync( + configPath, + JSON.stringify({ + httpApis: { + status: { + name: "Status", + description: "Status API.", + baseUrl: "http://127.0.0.1:1", + auth: { type: "none" }, + actions: { check: { method: "GET", path: "/check" } }, + }, + }, + }), + ); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + const store = new RemoteServerCredentialStore({ dir: join(root, "remote-state") }); + const operator = issueOperator(store, "Direct Operator"); + const activity = new DashboardActivityLog({ dir: join(root, "activity") }); + const operations = createCurrentHostOperations({ + engine, + control: { configPath, projectConfigPath, authDir, globalCapletsRoot, globalLockfilePath }, + activityLog: activity, + remoteCredentialStore: store, + version: "test-version", + }); + return { + root, + configPath, + engine, + store, + activity, + operations, + principal: { + clientId: operator.clientId, + clientLabel: operator.clientLabel, + hostUrl: operator.hostUrl, + role: "operator" as const, + }, + }; +} + +function issueOperator(store: RemoteServerCredentialStore, clientLabel: string) { + const pending = store.createPendingLogin({ + hostUrl: "http://127.0.0.1:5387/", + requestedRole: "operator", + clientLabel, + }); + store.approvePendingLogin({ operatorCode: pending.operatorCode }); + return store.completePendingLogin({ + hostUrl: "http://127.0.0.1:5387/", + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }); +} + +function catalogSource(root: string): string { + const source = join(root, "catalog-source"); + const caplets = join(source, "caplets"); + mkdirSync(caplets, { recursive: true }); + writeFileSync( + join(caplets, "sample.md"), + [ + "---", + "name: Sample", + "description: Sample Caplet.", + "httpApi:", + " baseUrl: http://127.0.0.1:1", + " auth:", + " type: none", + " actions:", + " check:", + " method: GET", + " path: /check", + "---", + "", + "# Sample", + "", + ].join("\n"), + ); + return source; +} + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirs.push(dir); + return dir; +} diff --git a/packages/core/test/dashboard-api.test.ts b/packages/core/test/dashboard-api.test.ts index 8b9dd8fa..d5695527 100644 --- a/packages/core/test/dashboard-api.test.ts +++ b/packages/core/test/dashboard-api.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { CapletsEngine } from "../src/engine"; import { RemoteServerCredentialStore } from "../src/remote/server-credential-store"; import { createHttpServeApp } from "../src/serve/http"; @@ -106,6 +106,26 @@ describe("dashboard API read model", () => { }, }); + await setup.engine.close(); + }); + it("maps authenticated collaborator faults to internal errors without ending the session", async () => { + const setup = await authenticatedDashboard(); + vi.spyOn(setup.engine, "enabledServers").mockImplementation(() => { + throw new Error("collaborator failed with cap_remote_access_sensitive_value"); + }); + + const response = await dashboardGet(setup, "/dashboard/api/caplets"); + + expect(response.status).toBe(500); + const body = await response.text(); + expect(JSON.parse(body)).toMatchObject({ + ok: false, + error: { code: "INTERNAL_ERROR" }, + }); + expect(body).not.toContain("collaborator failed"); + expect(body).not.toContain("cap_remote_access_sensitive_value"); + expect(response.headers.get("set-cookie")).toBeNull(); + await setup.engine.close(); }); }); diff --git a/packages/core/test/dashboard-catalog.test.ts b/packages/core/test/dashboard-catalog.test.ts index 4031a336..bb1fc24e 100644 --- a/packages/core/test/dashboard-catalog.test.ts +++ b/packages/core/test/dashboard-catalog.test.ts @@ -178,6 +178,57 @@ describe("dashboard caplets and catalog APIs", () => { error: { code: "CONFIG_NOT_FOUND" }, }); + await setup.engine.close(); + }); + it("returns the same redacted catalog failure through dashboard and bearer adapters", async () => { + const setup = await authenticatedDashboard(); + const source = + "https://operator:credential@127.0.0.1:1/private-repository?token=transport_secret"; + + const dashboardResponse = await dashboardPost(setup, "/dashboard/api/catalog/install", { + source, + capletId: "sample", + }); + expect(dashboardResponse.status).toBe(404); + const dashboardError = (await dashboardResponse.json()) as { + error: { code: string; message: string }; + }; + + const pending = setup.store.createPendingLogin({ + hostUrl: "http://127.0.0.1:5387/", + requestedRole: "operator", + }); + setup.store.approvePendingLogin({ operatorCode: pending.operatorCode }); + const operator = setup.store.completePendingLogin({ + hostUrl: "http://127.0.0.1:5387/", + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }); + const bearerResponse = await setup.app.request("http://127.0.0.1:5387/v1/admin", { + method: "POST", + headers: { + authorization: `Bearer ${operator.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + command: "install", + arguments: { repo: source, capletIds: ["sample"] }, + }), + }); + expect(bearerResponse.status).toBe(200); + const bearerError = (await bearerResponse.json()) as { + error: { code: string; message: string }; + }; + + expect(dashboardError.error).toEqual({ + code: "CONFIG_NOT_FOUND", + message: "Could not clone repo [REDACTED]", + }); + expect(bearerError.error).toEqual(dashboardError.error); + expect(JSON.stringify({ dashboardError, bearerError })).not.toContain("credential"); + expect(JSON.stringify({ dashboardError, bearerError })).not.toContain("transport_secret"); + expect(JSON.stringify({ dashboardError, bearerError })).not.toContain("127.0.0.1"); + await setup.engine.close(); }); }); diff --git a/packages/core/test/dashboard-session.test.ts b/packages/core/test/dashboard-session.test.ts index d043e14d..2a8ac92a 100644 --- a/packages/core/test/dashboard-session.test.ts +++ b/packages/core/test/dashboard-session.test.ts @@ -70,6 +70,54 @@ describe("dashboard sessions", () => { await engine.close(); }); + it("denies non-loopback development administration while preserving runtime access", async () => { + const { app, engine } = developmentTestApp({ host: "0.0.0.0", loopback: false }); + const baseUrl = "http://10.0.0.5:5387"; + + const session = await app.request(`${baseUrl}/dashboard/api/session`); + expect(session.status).toBe(403); + + const admin = await app.request(`${baseUrl}/v1/admin`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ command: "list", arguments: {} }), + }); + expect(admin.status).toBe(403); + + const reveal = await app.request(`${baseUrl}/dashboard/api/vault/reveal`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-caplets-csrf": "development_unauthenticated", + }, + body: JSON.stringify({ key: "GH_TOKEN", confirmation: "reveal GH_TOKEN" }), + }); + expect(reveal.status).toBe(403); + + const attach = await app.request(`${baseUrl}/v1/attach/manifest`); + expect(attach.status).toBe(200); + + const mcp = await app.request(`${baseUrl}/v1/mcp`, { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "development-test", version: "1.0.0" }, + }, + }), + }); + expect(mcp.status).toBe(200); + + await engine.close(); + }); it("logs out development operator sessions without cookie-backed sessions", async () => { const { app, engine } = developmentTestApp(); @@ -346,11 +394,11 @@ function testApp(overrides: Partial = {}) { return { app, engine, store, stateDir, context }; } -function developmentTestApp() { +function developmentTestApp(overrides: Partial = {}) { const stateDir = tempDir("caplets-dashboard-dev-state-"); const context = testContext(); const engine = engineFor(context); - const app = createHttpServeApp(developmentHttpOptions(stateDir), engine, { + const app = createHttpServeApp(developmentHttpOptions(stateDir, overrides), engine, { writeErr: () => {}, control: context, }); @@ -384,11 +432,15 @@ function httpOptions( }; } -function developmentHttpOptions(stateDir: string): HttpServeOptions { +function developmentHttpOptions( + stateDir: string, + overrides: Partial = {}, +): HttpServeOptions { return { ...httpOptions(stateDir), auth: { type: "development_unauthenticated" }, allowUnauthenticatedHttp: true, + ...overrides, }; } diff --git a/packages/core/test/dashboard-vault.test.ts b/packages/core/test/dashboard-vault.test.ts index 72be5416..e63dd19e 100644 --- a/packages/core/test/dashboard-vault.test.ts +++ b/packages/core/test/dashboard-vault.test.ts @@ -1,15 +1,17 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { CapletsEngine } from "../src/engine"; import { RemoteServerCredentialStore } from "../src/remote/server-credential-store"; import { createHttpServeApp } from "../src/serve/http"; +import { FileVaultStore } from "../src/vault"; import type { HttpServeOptions } from "../src/serve/options"; const dirs: string[] = []; afterEach(() => { + vi.restoreAllMocks(); for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); @@ -35,12 +37,17 @@ describe("dashboard Vault APIs", () => { storedKey: "GH_TOKEN", referenceName: "API_TOKEN", capletId: "status", - origin: { kind: "global-config", path: setup.context.configPath }, }); expect(grant.status).toBe(200); - const grantText = await grant.text(); - expect(grantText).toContain('"referenceName":"API_TOKEN"'); - expect(grantText).not.toContain(setup.context.configPath); + const grantBody = await grant.json(); + expect(grantBody).toMatchObject({ + grant: { + referenceName: "API_TOKEN", + capletId: "status", + origin: { kind: "global-config" }, + }, + }); + expect(JSON.stringify(grantBody)).not.toContain(setup.context.configPath); const revoke = await dashboardPost(setup, "/dashboard/api/vault/grants/revoke", { storedKey: "GH_TOKEN", @@ -100,6 +107,72 @@ describe("dashboard Vault APIs", () => { expect(text).toContain('"action":"vault_value_revealed"'); expect(text).not.toContain("remote_secret"); + await setup.engine.close(); + }); + it("maps reveal collaborator faults to internal errors without ending the session", async () => { + const setup = await authenticatedDashboard(); + await dashboardPost(setup, "/dashboard/api/vault/values", { + key: "GH_TOKEN", + value: "remote_secret", + }); + vi.spyOn(FileVaultStore.prototype, "resolveValue").mockImplementation(() => { + throw new Error("Vault read failed at /tmp/private token=cap_remote_access_sensitive_value"); + }); + + const response = await dashboardPost(setup, "/dashboard/api/vault/reveal", { + key: "GH_TOKEN", + confirmation: "reveal GH_TOKEN", + }); + + expect(response.status).toBe(500); + const body = await response.text(); + expect(JSON.parse(body)).toMatchObject({ + ok: false, + error: { code: "INTERNAL_ERROR", message: "Current Host administration failed." }, + }); + expect(body).not.toContain("/tmp/private"); + expect(body).not.toContain("cap_remote_access_sensitive_value"); + expect(response.headers.get("set-cookie")).toBeNull(); + + await setup.engine.close(); + }); + it("preserves identical opaque Vault bytes through dashboard and bearer adapters", async () => { + const setup = await authenticatedDashboard(); + const value = " \t\r\n "; + + const dashboardResponse = await dashboardPost(setup, "/dashboard/api/vault/values", { + key: "DASHBOARD_WHITESPACE", + value, + }); + expect(dashboardResponse.status).toBe(200); + + const pending = setup.store.createPendingLogin({ + hostUrl: "http://127.0.0.1:5387/", + requestedRole: "operator", + }); + setup.store.approvePendingLogin({ operatorCode: pending.operatorCode }); + const operator = setup.store.completePendingLogin({ + hostUrl: "http://127.0.0.1:5387/", + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }); + const bearerResponse = await setup.app.request("http://127.0.0.1:5387/v1/admin", { + method: "POST", + headers: { + authorization: `Bearer ${operator.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + command: "vault_set", + arguments: { name: "BEARER_WHITESPACE", value }, + }), + }); + expect(bearerResponse.status).toBe(200); + + const vault = new FileVaultStore({ root: join(setup.context.authDir, "vault") }); + expect(vault.resolveValue("DASHBOARD_WHITESPACE")).toBe(value); + expect(vault.resolveValue("BEARER_WHITESPACE")).toBe(value); + await setup.engine.close(); }); }); diff --git a/packages/core/test/remote-control-dispatch.test.ts b/packages/core/test/remote-control-dispatch.test.ts index f5241b3e..1bc0ca9a 100644 --- a/packages/core/test/remote-control-dispatch.test.ts +++ b/packages/core/test/remote-control-dispatch.test.ts @@ -13,6 +13,8 @@ vi.mock("@modelcontextprotocol/sdk/client/auth", async (importOriginal) => ({ import { readTokenBundle, writeTokenBundle } from "../src/auth"; import { RemoteAuthFlowStore } from "../src/remote-control/auth-flow"; import { dispatchRemoteCliRequest } from "../src/remote-control/dispatch"; +import { createCurrentHostOperations } from "../src/current-host/operations"; +import { DashboardActivityLog } from "../src/dashboard/activity-log"; import { FileVaultStore } from "../src/vault"; const dirs: string[] = []; @@ -136,7 +138,7 @@ describe("dispatchRemoteCliRequest", () => { const response = await dispatchRemoteCliRequest( { - command: "password=hunter2" as never, + command: "password=hunter2", arguments: { authorization: "Authorization: Basic abc123", clientSecret: "client_secret=secret-value", @@ -184,10 +186,12 @@ describe("dispatchRemoteCliRequest", () => { }, }, { ...context, authDir }, + currentHostAdministration({ ...context, authDir }), ); const list = await dispatchRemoteCliRequest( { command: "vault_access_list", arguments: {} }, { ...context, authDir }, + currentHostAdministration({ ...context, authDir }), ); const inspect = await dispatchRemoteCliRequest( { @@ -237,6 +241,7 @@ describe("dispatchRemoteCliRequest", () => { }, }, { ...context, authDir }, + currentHostAdministration({ ...context, authDir }), ); const store = new FileVaultStore({ root: join(authDir, "vault") }); @@ -262,6 +267,7 @@ describe("dispatchRemoteCliRequest", () => { }, }, { ...context, authDir }, + currentHostAdministration({ ...context, authDir }), ); expect(response).toMatchObject({ ok: false }); @@ -461,6 +467,7 @@ describe("dispatchRemoteCliRequest", () => { dispatchRemoteCliRequest( { command: "install", arguments: { repo: sourceRepo } }, installContext, + currentHostAdministration(installContext), ), ).resolves.toMatchObject({ ok: true, result: { remote: true } }); }); @@ -497,6 +504,11 @@ describe("dispatchRemoteCliRequest", () => { dispatchRemoteCliRequest( { command: "install", arguments: { repo: sourceRepo, capletIds: ["sample"] } }, { ...context, globalCapletsRoot: globalRoot, globalLockfilePath }, + currentHostAdministration({ + ...context, + globalCapletsRoot: globalRoot, + globalLockfilePath, + }), ), ).resolves.toMatchObject({ ok: true, result: { remote: true } }); @@ -543,6 +555,7 @@ describe("dispatchRemoteCliRequest", () => { }, }, context, + currentHostAdministration(context), ); expect(response).toMatchObject({ @@ -868,6 +881,37 @@ function testContext(options: { writeConfig?: boolean } = {}) { }; } +type DispatchAdministrationContext = { + tempRoot: string; + configPath: string; + projectConfigPath: string; + authDir?: string | undefined; + globalCapletsRoot?: string | undefined; + globalLockfilePath?: string | undefined; +}; + +function currentHostAdministration(context: DispatchAdministrationContext) { + return { + operations: createCurrentHostOperations({ + engine: { enabledServers: () => [] }, + control: { + configPath: context.configPath, + projectConfigPath: context.projectConfigPath, + authDir: context.authDir, + globalCapletsRoot: context.globalCapletsRoot, + globalLockfilePath: context.globalLockfilePath, + }, + activityLog: new DashboardActivityLog({ dir: join(context.tempRoot, "activity") }), + version: "test-version", + }), + principal: { + clientId: "rcli_abcdefghijklmnop", + hostUrl: "http://127.0.0.1:5387/", + role: "operator" as const, + }, + }; +} + function remoteFixtureWithOAuth() { const dir = mkdtempSync(join(tmpdir(), "caplets-dispatch-auth-")); dirs.push(dir); diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index c24ac436..c3d3106c 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -6,6 +6,7 @@ import { serve, type WebSocketServerLike } from "@hono/node-server"; import { afterEach, describe, expect, it, vi } from "vitest"; import WebSocket, { WebSocketServer } from "ws"; import { CapletsEngine } from "../src/engine"; +import { DashboardActivityLog } from "../src/dashboard/activity-log"; import type { CapletsEngineOptions } from "../src/engine"; import { CapletsError } from "../src/errors"; import { RemoteAuthFlowStore } from "../src/remote-control/auth-flow"; @@ -21,6 +22,7 @@ import { import * as serveHttpModule from "../src/serve/http"; import type { HttpAttachSessionFactory, HttpMcpSessionFactory } from "../src/serve/http"; import { CAPLETS_ATTACH_SESSION_HEADER, type AttachManifest } from "../src/attach/api"; +import { buildManifestExposureProjection } from "../src/exposure/projection"; import type { HttpServeOptions } from "../src/serve/options"; const dirs: string[] = []; @@ -213,6 +215,37 @@ describe("createHttpServeApp", () => { }); expect(mcp.status).toBe(200); + const operatorAttach = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { authorization: `Bearer ${operatorCredentials.accessToken}` }, + }); + expect(operatorAttach.status).toBe(403); + + const operatorProjectBinding = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/bind_123/status", + { headers: { authorization: `Bearer ${operatorCredentials.accessToken}` } }, + ); + expect(operatorProjectBinding.status).toBe(403); + + const operatorMcp = await app.request("http://127.0.0.1:5387/v1/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${operatorCredentials.accessToken}`, + host: "127.0.0.1:5387", + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "operator", version: "1.0.0" }, + }, + }), + }); + expect(operatorMcp.status).toBe(403); await engine.close(); }); @@ -537,6 +570,85 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("allows both credential roles to revoke only their own remote client", async () => { + const context = testContext(); + const engine = new CapletsEngine({ + configPath: context.configPath, + projectConfigPath: context.projectConfigPath, + watch: false, + }); + const store = remoteCredentialStore(); + const access = pairedClient(store); + const otherAccess = pairedClient(store); + const operator = pairedClient(store, "http://127.0.0.1:5387/", "operator"); + const otherOperator = pairedClient(store, "http://127.0.0.1:5387/", "operator"); + const app = createHttpServeApp(httpOptions({ auth: { type: "remote_credentials" } }), engine, { + writeErr: () => {}, + control: context, + remoteCredentialStore: store, + }); + + const revokedAccess = await app.request("http://127.0.0.1:5387/v1/remote/client", { + method: "DELETE", + headers: { + authorization: `Bearer ${access.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ clientId: otherAccess.clientId }), + }); + expect(revokedAccess.status).toBe(200); + await expect(revokedAccess.json()).resolves.toEqual({ + revoked: true, + clientId: access.clientId, + }); + expect( + await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { authorization: `Bearer ${access.accessToken}` }, + }), + ).toHaveProperty("status", 401); + expect( + await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { authorization: `Bearer ${otherAccess.accessToken}` }, + }), + ).toHaveProperty("status", 200); + + const revokedOperator = await app.request("http://127.0.0.1:5387/v1/remote/client", { + method: "DELETE", + headers: { + authorization: `Bearer ${operator.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ clientId: otherOperator.clientId }), + }); + expect(revokedOperator.status).toBe(200); + await expect(revokedOperator.json()).resolves.toEqual({ + revoked: true, + clientId: operator.clientId, + }); + expect( + await app.request("http://127.0.0.1:5387/v1/admin", { + method: "POST", + headers: { + authorization: `Bearer ${operator.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ command: "list", arguments: {} }), + }), + ).toHaveProperty("status", 401); + expect( + await app.request("http://127.0.0.1:5387/v1/admin", { + method: "POST", + headers: { + authorization: `Bearer ${otherOperator.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ command: "list", arguments: {} }), + }), + ).toHaveProperty("status", 200); + + await engine.close(); + }); + it("requires explicit public origin before trusted proxy headers select remote credential audiences", async () => { const { engine } = testEngine(); const store = remoteCredentialStore(); @@ -668,6 +780,50 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("records the validated Operator client for administrative Vault mutations", async () => { + const context = testContext(); + const authDir = tempDir("caplets-http-admin-vault-auth-"); + const engine = new CapletsEngine({ + configPath: context.configPath, + projectConfigPath: context.projectConfigPath, + watch: false, + }); + const store = remoteCredentialStore(); + const operator = pairedClient(store, "http://127.0.0.1:5387/", "operator"); + const app = createHttpServeApp( + httpOptions({ auth: { type: "remote_credentials" }, remoteCredentialStateDir: store.dir }), + engine, + { + writeErr: () => {}, + control: { ...context, authDir }, + remoteCredentialStore: store, + }, + ); + + const response = await app.request("http://127.0.0.1:5387/v1/admin", { + method: "POST", + headers: { + authorization: `Bearer ${operator.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + command: "vault_set", + arguments: { name: "GH_TOKEN", value: "administrative_secret" }, + }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + result: { key: "GH_TOKEN", present: true }, + }); + expect( + new DashboardActivityLog({ dir: store.dir }).list({ action: "vault_set" }).entries, + ).toEqual([expect.objectContaining({ actorClientId: operator.clientId, action: "vault_set" })]); + + await engine.close(); + }); + it("exposes authenticated Project Binding status under the attach namespace", async () => { const { engine } = testEngine(); const store = remoteCredentialStore(); @@ -1364,16 +1520,24 @@ describe("createHttpServeApp", () => { projectConfigPath: context.projectConfigPath, watch: false, }); - const app = createHttpServeApp(httpOptions({ path: "/caplets" }), engine, { - writeErr: () => {}, - control: context, - }); + const store = remoteCredentialStore(); + const operator = pairedClient(store, "https://10.0.0.5:5387/caplets/", "operator"); + const app = createHttpServeApp( + httpOptions({ path: "/caplets", auth: { type: "remote_credentials" } }), + engine, + { + writeErr: () => {}, + control: context, + remoteCredentialStore: store, + }, + ); - const response = await app.request("http://10.0.0.5:5387/caplets/v1/admin", { + const response = await app.request("https://10.0.0.5:5387/caplets/v1/admin", { method: "POST", headers: { + authorization: `Bearer ${operator.accessToken}`, "content-type": "application/json", - "x-forwarded-proto": "https", + "x-forwarded-proto": "http", "x-forwarded-host": "caplets.example.com", }, body: JSON.stringify({ command: "auth_login_start", arguments: { server: "remote" } }), @@ -1385,7 +1549,7 @@ describe("createHttpServeApp", () => { const result = (body as { result: { authorizationUrl: string } }).result; const authorizationUrl = new URL(result.authorizationUrl); expect(authorizationUrl.searchParams.get("redirect_uri")).toMatch( - /^http:\/\/10\.0\.0\.5:5387\/caplets\/v1\/admin\/auth\/callback\//u, + /^https:\/\/10\.0\.0\.5:5387\/caplets\/v1\/admin\/auth\/callback\//u, ); await engine.close(); @@ -1398,14 +1562,22 @@ describe("createHttpServeApp", () => { projectConfigPath: context.projectConfigPath, watch: false, }); - const app = createHttpServeApp(httpOptions({ path: "/caplets" }), engine, { - writeErr: () => {}, - control: context, - }); + const store = remoteCredentialStore(); + const operator = pairedClient(store, "https://10.0.0.5:5387/caplets/", "operator"); + const app = createHttpServeApp( + httpOptions({ path: "/caplets", auth: { type: "remote_credentials" } }), + engine, + { + writeErr: () => {}, + control: context, + remoteCredentialStore: store, + }, + ); - const response = await app.request("http://10.0.0.5:5387/caplets/v1/admin", { + const response = await app.request("https://10.0.0.5:5387/caplets/v1/admin", { method: "POST", headers: { + authorization: `Bearer ${operator.accessToken}`, "content-type": "application/json", host: "attacker.example.com", }, @@ -1418,30 +1590,43 @@ describe("createHttpServeApp", () => { const result = (body as { result: { authorizationUrl: string } }).result; const authorizationUrl = new URL(result.authorizationUrl); expect(authorizationUrl.searchParams.get("redirect_uri")).toMatch( - /^http:\/\/10\.0\.0\.5:5387\/caplets\/v1\/admin\/auth\/callback\//u, + /^https:\/\/10\.0\.0\.5:5387\/caplets\/v1\/admin\/auth\/callback\//u, ); await engine.close(); }); - it("uses forwarded host and proto for remote auth callback URLs when proxy trust is enabled", async () => { + it("uses explicit public origin for auth callback URLs behind trusted proxies", async () => { const context = testContext({ oauth: true }); const engine = new CapletsEngine({ configPath: context.configPath, projectConfigPath: context.projectConfigPath, watch: false, }); - const app = createHttpServeApp(httpOptions({ path: "/caplets", trustProxy: true }), engine, { - writeErr: () => {}, - control: context, - }); + const store = remoteCredentialStore(); + const operator = pairedClient(store, "https://caplets.example.com/caplets/", "operator"); + const app = createHttpServeApp( + httpOptions({ + path: "/caplets", + auth: { type: "remote_credentials" }, + trustProxy: true, + publicOrigin: "https://caplets.example.com", + }), + engine, + { + writeErr: () => {}, + control: context, + remoteCredentialStore: store, + }, + ); const response = await app.request("http://10.0.0.5:5387/caplets/v1/admin", { method: "POST", headers: { + authorization: `Bearer ${operator.accessToken}`, "content-type": "application/json", "x-forwarded-proto": "https", - "x-forwarded-host": "caplets.example.com", + "x-forwarded-host": "attacker.example.net", }, body: JSON.stringify({ command: "auth_login_start", arguments: { server: "remote" } }), }); @@ -1512,6 +1697,67 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("rejects an old HTTP Attach export after reload hides its Caplet", async () => { + const config = { + options: { exposure: "direct" }, + httpApis: { + status: { + name: "Status", + description: "Read status.", + baseUrl: "http://127.0.0.1:1", + auth: { type: "none" }, + actions: { check: { method: "GET", path: "/check" } }, + }, + }, + }; + const { engine, configPath } = testEngine(config); + const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} }); + const manifestResponse = await app.request("http://127.0.0.1:5387/v1/attach/manifest"); + const previous = (await manifestResponse.json()) as AttachManifest; + const previousTool = previous.tools[0]; + expect(previousTool).toBeDefined(); + + writeFileSync( + configPath, + JSON.stringify({ + ...config, + httpApis: { + status: { + ...config.httpApis.status, + disabled: true, + }, + }, + }), + ); + await engine.reload(); + + const nextResponse = await app.request("http://127.0.0.1:5387/v1/attach/manifest"); + const next = (await nextResponse.json()) as AttachManifest; + expect(next.revision).not.toBe(previous.revision); + expect(next.tools).toEqual([]); + expect(next.diagnostics).toEqual([ + expect.objectContaining({ code: "ATTACH_CAPLET_DISABLED", capletId: "status" }), + ]); + + const invoked = await app.request("http://127.0.0.1:5387/v1/attach/invoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + revision: previous.revision, + kind: "tool", + exportId: previousTool?.exportId, + input: {}, + }), + }); + expect(invoked.status).toBe(409); + await expect(invoked.json()).resolves.toMatchObject({ + ok: false, + error: { code: "ATTACH_MANIFEST_STALE" }, + }); + + await engine.close(); + }); + it("does not advertise attach routes when an HTTP app bridges an attached session", async () => { const { engine } = testEngine(); const app = createHttpServeApp(httpOptions(), engine, { @@ -2282,32 +2528,29 @@ describe("createHttpServeApp", () => { }); it("recomputes attach projections before invokes so stale downstream surfaces are rejected", async () => { - const caplet = { - server: "docs", - name: "Docs", - description: "Docs.", - backend: "mcp", - command: process.execPath, - }; let downstreamToolName = "read"; const engine = { onReload: () => () => undefined, - exposureSnapshot: async () => ({ - callableCaplets: [], - progressiveCaplets: [], - codeModeCaplets: [], - directTools: [ - { - caplet, - downstreamName: downstreamToolName, - name: `docs__${downstreamToolName}`, - tool: { name: downstreamToolName, inputSchema: { type: "object" } }, - }, - ], - directResources: [], - directResourceTemplates: [], - directPrompts: [], - hiddenCaplets: [], + currentExposureGeneration: () => 0, + exposureProjection: async () => ({ + generation: 0, + projection: buildManifestExposureProjection({ + caplets: [], + tools: [ + { + kind: "tool", + capletId: "docs", + downstreamName: downstreamToolName, + name: `docs__${downstreamToolName}`, + inputSchema: { type: "object" }, + shadowing: "forbid", + }, + ], + resources: [], + resourceTemplates: [], + prompts: [], + completions: [], + }), }), execute: async () => ({ called: true }), } as unknown as CapletsEngine; @@ -2816,7 +3059,11 @@ function tempDir(prefix: string): string { return dir; } -function testEngine(config: Record = {}): { engine: CapletsEngine } { +function testEngine(config: Record = {}): { + engine: CapletsEngine; + configPath: string; + projectConfigPath: string; +} { const context = testContext(config); return { engine: new CapletsEngine({ @@ -2824,6 +3071,8 @@ function testEngine(config: Record = {}): { engine: CapletsEngi projectConfigPath: context.projectConfigPath, watch: false, }), + configPath: context.configPath, + projectConfigPath: context.projectConfigPath, }; } From 1907814a68f566dbdb1164a94efe18b1e89308cd Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 10 Jul 2026 07:04:13 -0400 Subject: [PATCH 6/9] refactor(observability): enforce PostHog payload privacy Reconstruct approved anonymous event envelopes at PostHog before-send time, preserve transport identity without person profiles, and isolate provider failures across all public sites. Record verified provider-side IP discard separately from event-level GeoIP suppression. --- apps/catalog/src/scripts/observability.ts | 40 +- apps/catalog/test/observability.test.ts | 166 ++++++++- .../src/content/docs/privacy/indexing.mdx | 17 +- apps/docs/src/scripts/observability.ts | 40 +- apps/docs/test/observability.test.ts | 128 ++++++- apps/landing/src/scripts/observability.ts | 40 +- apps/landing/test/observability.test.ts | 130 ++++++- docs/product/anonymous-telemetry.md | 4 +- docs/product/telemetry-provider-readiness.md | 57 +-- packages/core/test/telemetry-docs.test.ts | 11 - packages/web-observability/src/index.ts | 13 +- packages/web-observability/src/posthog.ts | 122 ++++++ packages/web-observability/src/privacy.ts | 14 - .../test/posthog-provider-contract.test.ts | 352 ++++++++++++++++++ .../test/web-observability.test.ts | 13 - 15 files changed, 1008 insertions(+), 139 deletions(-) create mode 100644 packages/web-observability/src/posthog.ts create mode 100644 packages/web-observability/test/posthog-provider-contract.test.ts diff --git a/apps/catalog/src/scripts/observability.ts b/apps/catalog/src/scripts/observability.ts index fcfe5e62..61f721d5 100644 --- a/apps/catalog/src/scripts/observability.ts +++ b/apps/catalog/src/scripts/observability.ts @@ -4,8 +4,11 @@ import { buildWebEvent, bucketResultCount, bucketSearchTerm, + capturePostHogEvent, classifyRouteFamily, + createPostHogBeforeSend, filterSentryBrowserEvent, + sanitizePostHogCapture, type WebEventName, type WebEventPropertySet, type WebEventProperties, @@ -21,17 +24,26 @@ const environment = import.meta.env.PUBLIC_CAPLETS_ENVIRONMENT ?? import.meta.en let posthogEnabled = false; if (posthogToken) { - posthog.init(posthogToken, { - api_host: posthogHost || "https://us.i.posthog.com", - autocapture: false, - capture_pageview: false, - disable_session_recording: true, - disable_surveys: true, - disable_web_experiments: true, - disable_persistence: true, - persistence: "memory", - }); - posthogEnabled = true; + try { + posthog.init(posthogToken, { + api_host: posthogHost || "https://us.i.posthog.com", + advanced_disable_flags: true, + autocapture: false, + capture_pageview: false, + disable_session_recording: true, + disable_surveys: true, + disable_web_experiments: true, + disable_persistence: true, + persistence: "memory", + person_profiles: "never", + save_campaign_params: false, + save_referrer: false, + before_send: createPostHogBeforeSend(sanitizePostHogCapture), + }); + posthogEnabled = true; + } catch { + // Analytics initialization must never interrupt catalog behavior. + } } if (sentryDsn) { @@ -103,11 +115,7 @@ function captureCatalogEvent(name: WebEventName, properties: WebEventPropertySet name, properties: { surface, ...properties } as WebEventProperties, }); - posthog.capture(event.name, { - ...event.properties, - $process_person_profile: false, - $geoip_disable: true, - }); + capturePostHogEvent(posthog, event); } catch { // Analytics must never affect catalog behavior. } diff --git a/apps/catalog/test/observability.test.ts b/apps/catalog/test/observability.test.ts index 75d55369..933bd29f 100644 --- a/apps/catalog/test/observability.test.ts +++ b/apps/catalog/test/observability.test.ts @@ -1,11 +1,36 @@ // @vitest-environment happy-dom +import type * as WebObservabilityModule from "@caplets/web-observability"; import { beforeEach, describe, expect, it, vi } from "vitest"; +const posthogCapture = vi.hoisted(() => vi.fn()); +const posthogInit = vi.hoisted(() => vi.fn()); +const posthogSanitizer = vi.hoisted(() => vi.fn()); + +vi.mock("posthog-js", () => ({ + default: { capture: posthogCapture, init: posthogInit }, +})); + +vi.mock("@sentry/browser", () => ({ init: vi.fn() })); + +vi.mock("@caplets/web-observability", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, sanitizePostHogCapture: posthogSanitizer }; +}); + describe("catalog observability", () => { - beforeEach(() => { + beforeEach(async () => { vi.resetModules(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); + posthogCapture.mockReset(); + posthogInit.mockReset(); + posthogSanitizer.mockReset(); + const actual = await vi.importActual( + "@caplets/web-observability", + ); + posthogSanitizer.mockImplementation(actual.sanitizePostHogCapture); document.body.innerHTML = ""; Object.defineProperty(navigator, "clipboard", { configurable: true, @@ -28,8 +53,143 @@ describe("catalog observability", () => { ); }); - it("loads browser observability without provider env", async () => { - await expect(import("../src/scripts/observability")).resolves.toBeDefined(); + it("does not initialize or capture catalog analytics without provider env", async () => { + const { captureCatalogSearch } = await import("../src/scripts/observability"); + + captureCatalogSearch({ query: "raw private search", resultCount: 0 }); + + expect(posthogInit).not.toHaveBeenCalled(); + expect(posthogCapture).not.toHaveBeenCalled(); + }); + + it("installs privacy options and sanitizes the final augmented catalog envelope", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + + await import("../src/scripts/observability"); + + expect(posthogInit).toHaveBeenCalledWith( + "phc_test", + expect.objectContaining({ + api_host: "https://us.i.posthog.com", + advanced_disable_flags: true, + autocapture: false, + capture_pageview: false, + disable_persistence: true, + disable_session_recording: true, + disable_surveys: true, + disable_web_experiments: true, + person_profiles: "never", + persistence: "memory", + save_campaign_params: false, + save_referrer: false, + before_send: expect.any(Function), + }), + ); + const options = posthogInit.mock.calls[0]?.[1] as { + before_send: (payload: unknown) => unknown; + }; + + expect( + options.before_send({ + uuid: "0189d14f-4f1a-7000-8000-000000000003", + event: "caplets_catalog_search", + timestamp: new Date("2026-07-10T12:00:00.000Z"), + properties: { + token: "phc_test", + distinct_id: "anonymous-browser-identity", + $device_id: "anonymous-browser-identity", + $is_identified: false, + surface: "catalog", + route_family: "catalog", + page_family: "catalog", + section_category: "search", + search_length_bucket: "medium", + filter_category: "tag", + result_count_bucket: "few", + empty_state_category: "unknown", + $current_url: "https://catalog.caplets.dev/caplets?query=secret", + $unset: ["person_property"], + unknown_application_property: "not allowed", + }, + }), + ).toEqual({ + uuid: "0189d14f-4f1a-7000-8000-000000000003", + event: "caplets_catalog_search", + timestamp: new Date("2026-07-10T12:00:00.000Z"), + properties: { + token: "phc_test", + distinct_id: "anonymous-browser-identity", + surface: "catalog", + route_family: "catalog", + page_family: "catalog", + section_category: "search", + search_length_bucket: "medium", + filter_category: "tag", + result_count_bucket: "few", + empty_state_category: "unknown", + $process_person_profile: false, + $geoip_disable: true, + }, + }); + }); + + it("keeps catalog entry points live when init throws", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + posthogInit.mockImplementationOnce(() => { + throw new Error("init failure"); + }); + + const { captureCatalogSearch } = await import("../src/scripts/observability"); + + expect(() => captureCatalogSearch({ query: "search", resultCount: 1 })).not.toThrow(); + expect(posthogCapture).not.toHaveBeenCalled(); + }); + + it("returns null from its installed hook when the injected sanitizer throws", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + posthogSanitizer.mockImplementation(() => { + throw new Error("hook failure"); + }); + + await import("../src/scripts/observability"); + + const options = posthogInit.mock.calls[0]?.[1] as { + before_send: (payload: unknown) => unknown; + }; + expect(() => options.before_send({})).not.toThrow(); + expect(options.before_send({})).toBeNull(); + }); + + it("swallows capture failure while catalog search continues", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + posthogCapture.mockImplementation(() => { + throw new Error("capture failure"); + }); + + const { captureCatalogSearch } = await import("../src/scripts/observability"); + + expect(() => + captureCatalogSearch({ query: "raw private search", resultCount: 0, filterChanged: "tag" }), + ).not.toThrow(); + }); + + it("preserves categorical catalog search behavior", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + + const { captureCatalogSearch } = await import("../src/scripts/observability"); + posthogCapture.mockClear(); + captureCatalogSearch({ query: "search", resultCount: 0, filterChanged: "tag" }); + + expect(posthogCapture).toHaveBeenCalledWith("caplets_catalog_search", { + surface: "catalog", + route_family: "catalog", + page_family: "catalog", + section_category: "search", + search_length_bucket: "medium", + result_count_bucket: "zero", + filter_category: "tag", + empty_state_category: "no_results", + }); }); it("sends catalog worker errors as sanitized Sentry envelopes", async () => { diff --git a/apps/docs/src/content/docs/privacy/indexing.mdx b/apps/docs/src/content/docs/privacy/indexing.mdx index 6adb73f9..58d59277 100644 --- a/apps/docs/src/content/docs/privacy/indexing.mdx +++ b/apps/docs/src/content/docs/privacy/indexing.mdx @@ -8,13 +8,16 @@ Catalog indexing is separate from anonymous telemetry. Set `CAPLETS_DISABLE_CATALOG_INDEXING=1` to disable catalog indexing submissions. Anonymous telemetry intentionally excludes source URLs, Caplet IDs, hostnames, local paths, -tool arguments, tool outputs, and raw config. Public catalog indexing needs different data: -it publishes public-source metadata so other users can search for public Caplets. - -Landing, docs, and catalog analytics are anonymous telemetry, not public catalog indexing. -They use categorical page, search, filter, and install-intent events. They do not publish -source identity, Caplet identity, browser identity, raw URLs, raw search text, session -replay, or known-user profiles. +tool arguments, tool outputs, raw config, raw search text, session replay, and raw browser URLs. +Public catalog indexing needs different data: it publishes public-source metadata so other users +can search for public Caplets. + +Landing, docs, and catalog analytics are anonymous telemetry, not public catalog indexing. They +use categorical page, search, filter, and install-intent events. A final browser PostHog envelope +retains only the configured public project token and anonymous `distinct_id` for provider routing; +they are not user credentials, management keys, or token-shaped application data. +They do not publish source identity, Caplet identity, raw URLs, raw search text, session replay, or +known-user or person-profile attribution, and they never hand browser identity into CLI or runtime telemetry. ## What can become public diff --git a/apps/docs/src/scripts/observability.ts b/apps/docs/src/scripts/observability.ts index 9e2d5ee9..1c35de96 100644 --- a/apps/docs/src/scripts/observability.ts +++ b/apps/docs/src/scripts/observability.ts @@ -1,8 +1,11 @@ import * as Sentry from "@sentry/browser"; import { buildWebEvent, + capturePostHogEvent, classifyRouteFamily, + createPostHogBeforeSend, filterSentryBrowserEvent, + sanitizePostHogCapture, type WebEventName, type WebEventPropertySet, type WebEventProperties, @@ -18,17 +21,26 @@ const environment = import.meta.env.PUBLIC_CAPLETS_ENVIRONMENT ?? import.meta.en let posthogEnabled = false; if (posthogToken) { - posthog.init(posthogToken, { - api_host: posthogHost || "https://us.i.posthog.com", - autocapture: false, - capture_pageview: false, - disable_session_recording: true, - disable_surveys: true, - disable_web_experiments: true, - disable_persistence: true, - persistence: "memory", - }); - posthogEnabled = true; + try { + posthog.init(posthogToken, { + api_host: posthogHost || "https://us.i.posthog.com", + advanced_disable_flags: true, + autocapture: false, + capture_pageview: false, + disable_session_recording: true, + disable_surveys: true, + disable_web_experiments: true, + disable_persistence: true, + persistence: "memory", + person_profiles: "never", + save_campaign_params: false, + save_referrer: false, + before_send: createPostHogBeforeSend(sanitizePostHogCapture), + }); + posthogEnabled = true; + } catch { + // Analytics initialization must never interrupt docs behavior. + } } if (sentryDsn) { @@ -81,11 +93,7 @@ function captureDocsEvent(name: WebEventName, properties: WebEventPropertySet): name, properties: { surface, ...properties } as WebEventProperties, }); - posthog.capture(event.name, { - ...event.properties, - $process_person_profile: false, - $geoip_disable: true, - }); + capturePostHogEvent(posthog, event); } catch { // Analytics must never affect docs behavior. } diff --git a/apps/docs/test/observability.test.ts b/apps/docs/test/observability.test.ts index 2a0a5085..8f345801 100644 --- a/apps/docs/test/observability.test.ts +++ b/apps/docs/test/observability.test.ts @@ -1,9 +1,11 @@ // @vitest-environment happy-dom +import type * as WebObservabilityModule from "@caplets/web-observability"; import { beforeEach, describe, expect, it, vi } from "vitest"; const posthogCapture = vi.hoisted(() => vi.fn()); const posthogInit = vi.hoisted(() => vi.fn()); +const posthogSanitizer = vi.hoisted(() => vi.fn()); vi.mock("posthog-js", () => ({ default: { capture: posthogCapture, init: posthogInit }, @@ -11,27 +13,143 @@ vi.mock("posthog-js", () => ({ vi.mock("@sentry/browser", () => ({ init: vi.fn() })); +vi.mock("@caplets/web-observability", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, sanitizePostHogCapture: posthogSanitizer }; +}); + describe("docs observability", () => { - beforeEach(() => { + beforeEach(async () => { vi.resetModules(); vi.unstubAllEnvs(); vi.restoreAllMocks(); - posthogCapture.mockClear(); - posthogInit.mockClear(); + posthogCapture.mockReset(); + posthogInit.mockReset(); + posthogSanitizer.mockReset(); + const actual = await vi.importActual( + "@caplets/web-observability", + ); + posthogSanitizer.mockImplementation(actual.sanitizePostHogCapture); document.body.innerHTML = ""; }); - it("loads without provider env and handles navigation clicks", async () => { + it("does not initialize or capture without provider env while navigation listeners load", async () => { document.body.innerHTML = `Install`; const addEventListener = vi.spyOn(document, "addEventListener"); await expect(import("../src/scripts/observability")).resolves.toBeDefined(); document.querySelector("a")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(posthogInit).not.toHaveBeenCalled(); + expect(posthogCapture).not.toHaveBeenCalled(); expect(addEventListener).toHaveBeenCalledWith("click", expect.any(Function)); }); - it("classifies root-relative catalog links as catalog intent", async () => { + it("installs privacy options and sanitizes the final augmented envelope", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + + await import("../src/scripts/observability"); + + expect(posthogInit).toHaveBeenCalledWith( + "phc_test", + expect.objectContaining({ + api_host: "https://us.i.posthog.com", + advanced_disable_flags: true, + autocapture: false, + capture_pageview: false, + disable_persistence: true, + disable_session_recording: true, + disable_surveys: true, + disable_web_experiments: true, + person_profiles: "never", + persistence: "memory", + save_campaign_params: false, + save_referrer: false, + before_send: expect.any(Function), + }), + ); + const options = posthogInit.mock.calls[0]?.[1] as { + before_send: (payload: unknown) => unknown; + }; + + expect( + options.before_send({ + uuid: "0189d14f-4f1a-7000-8000-000000000002", + event: "caplets_site_pageview", + timestamp: new Date("2026-07-10T12:00:00.000Z"), + properties: { + token: "phc_test", + distinct_id: "anonymous-browser-identity", + $device_id: "anonymous-browser-identity", + $is_identified: false, + surface: "docs", + route_family: "docs", + page_family: "docs", + referrer_category: "direct", + $referrer: "https://search.example/?query=secret", + $set_once: { email: "person@example.com" }, + unknown_application_property: "not allowed", + }, + }), + ).toEqual({ + uuid: "0189d14f-4f1a-7000-8000-000000000002", + event: "caplets_site_pageview", + timestamp: new Date("2026-07-10T12:00:00.000Z"), + properties: { + token: "phc_test", + distinct_id: "anonymous-browser-identity", + surface: "docs", + route_family: "docs", + page_family: "docs", + referrer_category: "direct", + $process_person_profile: false, + $geoip_disable: true, + }, + }); + }); + + it("keeps listener registration active when init throws", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + posthogInit.mockImplementationOnce(() => { + throw new Error("init failure"); + }); + const addEventListener = vi.spyOn(document, "addEventListener"); + + await expect(import("../src/scripts/observability")).resolves.toBeDefined(); + + expect(posthogCapture).not.toHaveBeenCalled(); + expect(addEventListener).toHaveBeenCalledWith("click", expect.any(Function)); + }); + + it("returns null from its installed hook when the injected sanitizer throws", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + posthogSanitizer.mockImplementation(() => { + throw new Error("hook failure"); + }); + + await import("../src/scripts/observability"); + + const options = posthogInit.mock.calls[0]?.[1] as { + before_send: (payload: unknown) => unknown; + }; + expect(() => options.before_send({})).not.toThrow(); + expect(options.before_send({})).toBeNull(); + }); + + it("swallows capture failure while navigation continues", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + posthogCapture.mockImplementation(() => { + throw new Error("capture failure"); + }); + document.body.innerHTML = `Open catalog`; + + await expect(import("../src/scripts/observability")).resolves.toBeDefined(); + expect(() => + document.querySelector("a")?.dispatchEvent(new MouseEvent("click", { bubbles: true })), + ).not.toThrow(); + }); + + it("classifies root-relative catalog links as categorical catalog intent", async () => { vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); document.body.innerHTML = `Open catalog`; diff --git a/apps/landing/src/scripts/observability.ts b/apps/landing/src/scripts/observability.ts index fcd424b8..1af56f60 100644 --- a/apps/landing/src/scripts/observability.ts +++ b/apps/landing/src/scripts/observability.ts @@ -2,8 +2,11 @@ import * as Sentry from "@sentry/browser"; import { attributedInstallCommand, buildWebEvent, + capturePostHogEvent, classifyRouteFamily, + createPostHogBeforeSend, filterSentryBrowserEvent, + sanitizePostHogCapture, type WebEventName, type WebEventPropertySet, type WebEventProperties, @@ -19,17 +22,26 @@ const environment = import.meta.env.PUBLIC_CAPLETS_ENVIRONMENT ?? import.meta.en let posthogEnabled = false; if (posthogToken) { - posthog.init(posthogToken, { - api_host: posthogHost || "https://us.i.posthog.com", - autocapture: false, - capture_pageview: false, - disable_session_recording: true, - disable_surveys: true, - disable_web_experiments: true, - disable_persistence: true, - persistence: "memory", - }); - posthogEnabled = true; + try { + posthog.init(posthogToken, { + api_host: posthogHost || "https://us.i.posthog.com", + advanced_disable_flags: true, + autocapture: false, + capture_pageview: false, + disable_session_recording: true, + disable_surveys: true, + disable_web_experiments: true, + disable_persistence: true, + persistence: "memory", + person_profiles: "never", + save_campaign_params: false, + save_referrer: false, + before_send: createPostHogBeforeSend(sanitizePostHogCapture), + }); + posthogEnabled = true; + } catch { + // Analytics initialization must never interrupt landing behavior. + } } if (sentryDsn) { @@ -98,11 +110,7 @@ function captureLandingEvent(name: WebEventName, properties: WebEventPropertySet name, properties: { surface, ...properties } as WebEventProperties, }); - posthog.capture(event.name, { - ...event.properties, - $process_person_profile: false, - $geoip_disable: true, - }); + capturePostHogEvent(posthog, event); } catch { // Analytics must never affect site behavior. } diff --git a/apps/landing/test/observability.test.ts b/apps/landing/test/observability.test.ts index f86f3688..69ceccfc 100644 --- a/apps/landing/test/observability.test.ts +++ b/apps/landing/test/observability.test.ts @@ -1,9 +1,11 @@ // @vitest-environment happy-dom +import type * as WebObservabilityModule from "@caplets/web-observability"; import { beforeEach, describe, expect, it, vi } from "vitest"; const posthogCapture = vi.hoisted(() => vi.fn()); const posthogInit = vi.hoisted(() => vi.fn()); +const posthogSanitizer = vi.hoisted(() => vi.fn()); vi.mock("posthog-js", () => ({ default: { capture: posthogCapture, init: posthogInit }, @@ -11,8 +13,13 @@ vi.mock("posthog-js", () => ({ vi.mock("@sentry/browser", () => ({ init: vi.fn() })); +vi.mock("@caplets/web-observability", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, sanitizePostHogCapture: posthogSanitizer }; +}); + describe("landing observability", () => { - beforeEach(() => { + beforeEach(async () => { vi.resetModules(); vi.restoreAllMocks(); window.history.pushState({}, "", "/"); @@ -22,8 +29,13 @@ describe("landing observability", () => { value: { writeText: vi.fn().mockResolvedValue(undefined) }, }); vi.unstubAllEnvs(); - posthogCapture.mockClear(); - posthogInit.mockClear(); + posthogCapture.mockReset(); + posthogInit.mockReset(); + posthogSanitizer.mockReset(); + const actual = await vi.importActual( + "@caplets/web-observability", + ); + posthogSanitizer.mockImplementation(actual.sanitizePostHogCapture); window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), @@ -59,11 +71,119 @@ describe("landing observability", () => { expect(navigator.clipboard.writeText).toHaveBeenCalledWith("Read this setup skill"); }); - it("loads without initializing providers when env is absent", async () => { + it("does not initialize or capture when the PostHog env is absent", async () => { + await expect(import("../src/scripts/observability")).resolves.toBeDefined(); + + expect(posthogInit).not.toHaveBeenCalled(); + expect(posthogCapture).not.toHaveBeenCalled(); + }); + + it("installs privacy options and the shared final hook during initial initialization", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + + await import("../src/scripts/observability"); + + expect(posthogInit).toHaveBeenCalledWith( + "phc_test", + expect.objectContaining({ + api_host: "https://us.i.posthog.com", + advanced_disable_flags: true, + autocapture: false, + capture_pageview: false, + disable_persistence: true, + disable_session_recording: true, + disable_surveys: true, + disable_web_experiments: true, + person_profiles: "never", + persistence: "memory", + save_campaign_params: false, + save_referrer: false, + before_send: expect.any(Function), + }), + ); + const options = posthogInit.mock.calls[0]?.[1] as { + before_send: (payload: unknown) => unknown; + }; + + expect( + options.before_send({ + uuid: "0189d14f-4f1a-7000-8000-000000000001", + event: "caplets_site_pageview", + timestamp: new Date("2026-07-10T12:00:00.000Z"), + properties: { + token: "phc_test", + distinct_id: "anonymous-browser-identity", + $device_id: "anonymous-browser-identity", + $is_identified: false, + surface: "landing", + route_family: "home", + page_family: "home", + referrer_category: "direct", + $current_url: "https://caplets.dev/?secret=value", + $set: { email: "person@example.com" }, + unknown_application_property: "not allowed", + }, + $set: { email: "person@example.com" }, + }), + ).toEqual({ + uuid: "0189d14f-4f1a-7000-8000-000000000001", + event: "caplets_site_pageview", + timestamp: new Date("2026-07-10T12:00:00.000Z"), + properties: { + token: "phc_test", + distinct_id: "anonymous-browser-identity", + surface: "landing", + route_family: "home", + page_family: "home", + referrer_category: "direct", + $process_person_profile: false, + $geoip_disable: true, + }, + }); + }); + + it("leaves analytics disabled while listener registration continues when init throws", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + posthogInit.mockImplementationOnce(() => { + throw new Error("init failure"); + }); + const addEventListener = vi.spyOn(document, "addEventListener"); + + await expect(import("../src/scripts/observability")).resolves.toBeDefined(); + + expect(posthogCapture).not.toHaveBeenCalled(); + expect(addEventListener).toHaveBeenCalledWith("click", expect.any(Function)); + }); + + it("returns null from the installed hook when the injected sanitizer throws", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + posthogSanitizer.mockImplementation(() => { + throw new Error("hook failure"); + }); + + await import("../src/scripts/observability"); + + const options = posthogInit.mock.calls[0]?.[1] as { + before_send: (payload: unknown) => unknown; + }; + expect(() => options.before_send({})).not.toThrow(); + expect(options.before_send({})).toBeNull(); + }); + + it("swallows capture failure while link interaction continues", async () => { + vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); + posthogCapture.mockImplementation(() => { + throw new Error("capture failure"); + }); + document.body.innerHTML = `
Read docs
`; + await expect(import("../src/scripts/observability")).resolves.toBeDefined(); + expect(() => + document.querySelector("a")?.dispatchEvent(new MouseEvent("click", { bubbles: true })), + ).not.toThrow(); }); - it("classifies /caplets links as catalog navigation", async () => { + it("classifies /caplets links as categorical catalog navigation", async () => { vi.stubEnv("PUBLIC_CAPLETS_POSTHOG_TOKEN", "phc_test"); document.body.innerHTML = `
Browse catalog
`; diff --git a/docs/product/anonymous-telemetry.md b/docs/product/anonymous-telemetry.md index b2c917a6..c7734957 100644 --- a/docs/product/anonymous-telemetry.md +++ b/docs/product/anonymous-telemetry.md @@ -10,9 +10,9 @@ The first eligible interactive CLI run writes this notice to stderr only: Caplets collects anonymous telemetry for product usage and reliability. Disable it with CAPLETS_DISABLE_TELEMETRY=1 or `caplets telemetry disable`. ``` -Caplets never collects raw config, prompts, Code Mode code, tool arguments, tool outputs, logs, resource contents, prompt contents, file paths, URLs, hostnames, Caplet IDs, credentials, tokens, raw environment variables, raw error messages, or unsanitized stack traces. +Caplets never collects raw config, prompts, Code Mode code, tool arguments, tool outputs, logs, resource contents, prompt contents, file paths, URLs, hostnames, Caplet IDs, user credentials, provider management keys, token-shaped application data, raw environment variables, raw error messages, or unsanitized stack traces. -Public-site analytics use the same anonymous boundary. Landing, docs, and catalog events use categorical route, section, search, filter, and install-intent fields. Browser analytics disable session replay, heatmaps, broad autocapture, raw DOM capture, persistence, and known-user identification for this pass. +Public-site analytics use the same anonymous boundary. Landing, docs, and catalog events use categorical route, section, search, filter, and install-intent fields. Their final PostHog envelopes retain the configured public project token and an anonymous `distinct_id` only for provider routing; neither is a user credential, management key, or application token field. Known-user and person-profile attribution are prohibited, and no browser identity is handed into CLI or runtime telemetry. Browser analytics disable session replay, heatmaps, broad autocapture, raw DOM capture, persistence, and known-user identification for this pass. Install attribution is a short command-visible marker such as `CAPLETS_INSTALL_ATTRIBUTION=catalog_install`. It is categorical, nonsecret, one-way, and consumed on the first eligible successful runtime product event. It is not a browser visitor identifier and is not linked to a PostHog person profile. diff --git a/docs/product/telemetry-provider-readiness.md b/docs/product/telemetry-provider-readiness.md index 953fd112..45ba9c68 100644 --- a/docs/product/telemetry-provider-readiness.md +++ b/docs/product/telemetry-provider-readiness.md @@ -1,39 +1,40 @@ # Telemetry Provider Readiness -Version: 2 +Version: 3 -Status: ready for observability-enabled release when the release workflow and deploy workflow pass their telemetry environment checks. +Status: PostHog IP-discard gate verified for the unified production and preview project; overall release readiness remains conditional on the launch gates below. ## Provider Project -| Field | Value | -| ------------------------- | ---------------------------------------------------------------------- | -| Environment | production and preview | -| PostHog project | unified Caplets product analytics project | -| PostHog intake identifier | public project token only, no private API key | -| Sentry project | separate runtime, landing, docs, and catalog projects | -| Sentry intake identifier | per-surface DSN plus CI-only source-map auth token | -| Owner | Spirit-Led Software maintainer on release duty | -| Review date | every telemetry-enabled release | -| Review cadence | before every telemetry-enabled release and after provider key rotation | -| Retention | provider project retention reviewed before release | -| Ingestion monitoring | provider dashboards plus local delivery-health counters | -| Revocation | rotate PostHog project token, Sentry DSNs, and Sentry auth token | +| Field | Value | +| ------------------------- | -------------------------------------------------------------------------------------------------- | +| Environment | production and preview | +| PostHog project | unified Caplets product analytics project | +| PostHog intake identifier | public project token only, no private API key | +| PostHog IP data capture | Production and preview: **Settings → Project → General → IP data capture configuration → Discard** | +| Sentry project | separate runtime, landing, docs, and catalog projects | +| Sentry intake identifier | per-surface DSN plus CI-only source-map auth token | +| Owner | Spirit-Led Software maintainer on release duty | +| Review date | 2026-07-10 09:34 UTC | +| Review cadence | before every telemetry-enabled release and after provider key rotation | +| Retention | provider project retention reviewed before release | +| Ingestion monitoring | provider dashboards plus local delivery-health counters | +| Revocation | rotate PostHog project token, Sentry DSNs, and Sentry auth token | ## Launch Gates -| Gate | Required check | Status | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| Intake identifiers | Runtime release has `CAPLETS_POSTHOG_TOKEN`, `CAPLETS_RUNTIME_SENTRY_DSN`, `CAPLETS_SENTRY_AUTH_TOKEN`, `CAPLETS_SENTRY_ORG`, `CAPLETS_RUNTIME_SENTRY_PROJECT`, release, and environment values. | Ready | -| Site deploy env | Deploy and preview jobs have `PUBLIC_CAPLETS_POSTHOG_TOKEN`, `PUBLIC_CAPLETS_POSTHOG_HOST`, per-site browser DSNs, catalog worker DSN, Sentry org/project slugs, release, and environment values. | Ready | -| Package artifacts | No provider management keys, read tokens, admin tokens, or CI secrets are present in package contents, docs examples, tests, or logs. | Ready | -| PostHog IP and GeoIP | Runtime captures set `$geoip_disable: true`; browser SDKs disable autocapture, pageview autocapture, replay, web experiments, surveys, and persistence for this pass. | Ready | -| Sentry privacy | Runtime events use sanitized stack frames; browser events use `sendDefaultPii: false`; catalog worker errors send categorical route tags only. | Ready | -| Source maps | Runtime release uploads `packages/core/dist` source maps with `sentry-cli`; landing, docs, and catalog builds use the Sentry Vite plugin with hidden source maps when CI upload env is present. | Ready | -| Retention | Provider retention settings are reviewed by the release owner before broad telemetry-enabled rollout. | Ready | -| Ingestion monitoring | Provider ingestion errors, quota pressure, unexpected event spikes, and local delivery-health counters are checked before interpreting missing events as missing usage. | Ready | -| Revocation | A playbook exists to rotate shipped PostHog/Sentry intake identifiers and validate old identifiers no longer ingest data. | Ready | -| Readout mapping | `docs/product/telemetry-readout.md` maps decision questions to allowlisted event families and properties. | Ready | +| Gate | Required check | Status | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| Intake identifiers | Runtime release has `CAPLETS_POSTHOG_TOKEN`, `CAPLETS_RUNTIME_SENTRY_DSN`, `CAPLETS_SENTRY_AUTH_TOKEN`, `CAPLETS_SENTRY_ORG`, `CAPLETS_RUNTIME_SENTRY_PROJECT`, release, and environment values. | Ready | +| Site deploy env | Deploy and preview jobs have `PUBLIC_CAPLETS_POSTHOG_TOKEN`, `PUBLIC_CAPLETS_POSTHOG_HOST`, per-site browser DSNs, catalog worker DSN, Sentry org/project slugs, release, and environment values. | Ready | +| Package artifacts | No provider management keys, read tokens, admin tokens, or CI secrets are present in package contents, docs examples, tests, or logs. | Ready | +| PostHog IP and GeoIP | The unified active production and preview project uses **Settings → Project → General → IP data capture configuration → Discard**. PostHog project `Caplets` (`484122`) was verified through the provider API on 2026-07-10 at 09:34 UTC with `anonymize_ips: true`, whose provider contract drops the IP address from every ingested event. `$geoip_disable: true` remains required on accepted events, but it does not control or prove provider source-IP storage. Browser SDKs also disable autocapture, pageview autocapture, replay, web experiments, surveys, and persistence for this pass. | Ready | +| Sentry privacy | Runtime events use sanitized stack frames; browser events use `sendDefaultPii: false`; catalog worker errors send categorical route tags only. | Ready | +| Source maps | Runtime release uploads `packages/core/dist` source maps with `sentry-cli`; landing, docs, and catalog builds use the Sentry Vite plugin with hidden source maps when CI upload env is present. | Ready | +| Retention | Provider retention settings are reviewed by the release owner before broad telemetry-enabled rollout. | Ready | +| Ingestion monitoring | Provider ingestion errors, quota pressure, unexpected event spikes, and local delivery-health counters are checked before interpreting missing events as missing usage. | Ready | +| Revocation | A playbook exists to rotate shipped PostHog/Sentry intake identifiers and validate old identifiers no longer ingest data. | Ready | +| Readout mapping | `docs/product/telemetry-readout.md` maps decision questions to allowlisted event families and properties. | Ready | ## Source-Map Release Shape @@ -56,3 +57,5 @@ Status: ready for observability-enabled release when the release workflow and de ## Release Gate Telemetry-enabled packaging must pass `pnpm telemetry:check-release-env` before publishing. Site deploys must pass `pnpm telemetry:check-web-env` and `pnpm telemetry:check-source-maps` before production or same-repo preview deploys. Fork previews do not receive secrets and are skipped by the existing repository guard. + +Production and preview both use the unified PostHog project `Caplets` (`484122`). On 2026-07-10 at 09:34 UTC, the release owner enabled and re-read `anonymize_ips: true` through the PostHog project API; the provider schema defines that setting as dropping the IP address from every ingested event. Reverify this setting before every telemetry-enabled release and after provider project or token rotation. diff --git a/packages/core/test/telemetry-docs.test.ts b/packages/core/test/telemetry-docs.test.ts index 9acde716..50277bf6 100644 --- a/packages/core/test/telemetry-docs.test.ts +++ b/packages/core/test/telemetry-docs.test.ts @@ -59,15 +59,4 @@ describe("telemetry product docs", () => { expect(text).not.toMatch(/session replay.*in scope/iu); expect(text).not.toMatch(/known-user.*in scope/iu); }); - - it("keeps public catalog indexing separate from anonymous telemetry", () => { - const telemetry = read("docs/product/anonymous-telemetry.md"); - const indexing = read("apps/docs/src/content/docs/privacy/indexing.mdx"); - - expect(telemetry).toContain( - "Catalog public indexing remains separate from anonymous telemetry", - ); - expect(indexing).toContain("Landing, docs, and catalog analytics are anonymous telemetry"); - expect(indexing).toContain("They do not publish"); - }); }); diff --git a/packages/web-observability/src/index.ts b/packages/web-observability/src/index.ts index b7524eae..6ec25cbb 100644 --- a/packages/web-observability/src/index.ts +++ b/packages/web-observability/src/index.ts @@ -15,8 +15,13 @@ export { attributionMarkerForSurface, type WebAttributionMarker, } from "./attribution"; +export { assertWebEventSafeProperties, filterSentryBrowserEvent } from "./privacy"; export { - assertWebEventSafeProperties, - filterPostHogProperties, - filterSentryBrowserEvent, -} from "./privacy"; + capturePostHogEvent, + createPostHogBeforeSend, + sanitizePostHogCapture, + type PostHogCaptureCapability, + type PostHogFinalCapture, + type PostHogFinalProperties, + type PostHogFinalSanitizer, +} from "./posthog"; diff --git a/packages/web-observability/src/posthog.ts b/packages/web-observability/src/posthog.ts new file mode 100644 index 00000000..69056565 --- /dev/null +++ b/packages/web-observability/src/posthog.ts @@ -0,0 +1,122 @@ +import { buildWebEvent } from "./events"; +import type { WebEventName, WebEventProperties, WebEventPropertySet } from "./events"; +import { assertWebEventSafeProperties } from "./privacy"; + +export type PostHogFinalProperties = WebEventPropertySet & { + token: string; + distinct_id: string; + $process_person_profile: false; + $geoip_disable: true; +}; + +export type PostHogFinalCapture = { + uuid: string; + event: WebEventName; + properties: PostHogFinalProperties; + timestamp?: Date; +}; + +export type PostHogFinalSanitizer = (input: unknown) => PostHogFinalCapture | null; + +export type PostHogCaptureCapability = { + capture(name: WebEventName, properties: WebEventProperties): unknown; +}; + +export function sanitizePostHogCapture(input: unknown): PostHogFinalCapture | null { + try { + if (!isRecord(input) || !isRecord(input.properties)) return null; + + const uuid = nonemptyString(input.uuid); + const token = nonemptyString(input.properties.token); + const distinctId = nonemptyString(input.properties.distinct_id); + if (!uuid || !token || !distinctId) return null; + if (input.properties.$is_identified !== false || input.properties.$device_id !== distinctId) { + return null; + } + + const timestamp = input.timestamp; + if (timestamp !== undefined && !isTimestamp(timestamp)) return null; + + const event = buildFinalWebEvent(input.event, categoricalProperties(input.properties)); + if (!event) return null; + + return { + uuid, + event: event.name, + properties: { + token, + distinct_id: distinctId, + ...event.properties, + $process_person_profile: false, + $geoip_disable: true, + }, + ...(timestamp === undefined ? {} : { timestamp }), + }; + } catch { + return null; + } +} + +export function createPostHogBeforeSend( + sanitizer: PostHogFinalSanitizer = sanitizePostHogCapture, +): (input: unknown) => PostHogFinalCapture | null { + return (input) => { + try { + return sanitizer(input); + } catch { + return null; + } + }; +} + +export function capturePostHogEvent( + capability: PostHogCaptureCapability, + event: { name: Name; properties: WebEventProperties }, +): void { + try { + capability.capture(event.name, event.properties); + } catch { + // Provider capture must never interrupt a public-site interaction. + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonemptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function isTimestamp(value: unknown): value is Date { + return value instanceof Date && Number.isFinite(value.getTime()); +} + +function categoricalProperties(input: Record): WebEventPropertySet { + const properties: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (typeof value !== "string") continue; + try { + assertWebEventSafeProperties({ [key]: value } as never); + properties[key] = value; + } catch { + // Final provider payloads silently drop SDK and application additions. + } + } + return properties as WebEventPropertySet; +} + +function buildFinalWebEvent( + name: unknown, + properties: WebEventPropertySet, +): { name: WebEventName; properties: WebEventProperties } | null { + if (typeof name !== "string") return null; + try { + return buildWebEvent({ + name: name as WebEventName, + properties: properties as WebEventProperties, + }); + } catch { + return null; + } +} diff --git a/packages/web-observability/src/privacy.ts b/packages/web-observability/src/privacy.ts index 1ca1c9a3..d7480e78 100644 --- a/packages/web-observability/src/privacy.ts +++ b/packages/web-observability/src/privacy.ts @@ -75,20 +75,6 @@ export function assertWebEventSafeProperties( } } -export function filterPostHogProperties(input: Record): WebEventPropertySet { - const properties: WebEventPropertySet = {}; - for (const [key, value] of Object.entries(input)) { - if (!ALLOWED_WEB_KEYS.has(key) || typeof value !== "string") continue; - try { - assertWebEventSafeProperties({ [key]: value } as never); - Object.assign(properties, { [key]: value }); - } catch { - // Provider filters are defensive and should silently drop SDK-added raw fields. - } - } - return properties; -} - export function filterSentryBrowserEvent(event: Record): Record { const filtered: Record = {}; if (typeof event.release === "string") filtered.release = event.release; diff --git a/packages/web-observability/test/posthog-provider-contract.test.ts b/packages/web-observability/test/posthog-provider-contract.test.ts new file mode 100644 index 00000000..e810177a --- /dev/null +++ b/packages/web-observability/test/posthog-provider-contract.test.ts @@ -0,0 +1,352 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildWebEvent, + capturePostHogEvent, + createPostHogBeforeSend, + sanitizePostHogCapture, +} from "../src/index"; + +const transportFetch = vi.fn(); +const storageEntries = new Map(); +const browserStorage = { + clear(): void { + storageEntries.clear(); + }, + getItem(key: string): string | null { + return storageEntries.get(key) ?? null; + }, + key(index: number): string | null { + return [...storageEntries.keys()][index] ?? null; + }, + get length(): number { + return storageEntries.size; + }, + removeItem(key: string): void { + storageEntries.delete(key); + }, + setItem(key: string, value: string): void { + storageEntries.set(key, value); + }, +}; + +type MutableCaptureResult = { + uuid: unknown; + event: unknown; + properties: Record; + timestamp?: unknown; + [key: string]: unknown; +}; + +function posthog13950CaptureResult(): MutableCaptureResult { + return { + uuid: "0189d14f-4f1a-7000-8000-000000000001", + event: "caplets_catalog_search", + timestamp: new Date("2026-07-10T12:00:00.000Z"), + properties: { + token: "phc_public_project_token", + distinct_id: "anonymous-browser-identity", + $is_identified: false, + surface: "catalog", + route_family: "catalog", + page_family: "catalog", + section_category: "search", + search_length_bucket: "medium", + filter_category: "tag", + result_count_bucket: "few", + empty_state_category: "unknown", + $process_person_profile: true, + $geoip_disable: false, + $current_url: "https://catalog.caplets.dev/caplets?query=secret", + $initial_current_url: "https://catalog.caplets.dev/?utm_source=search", + $referrer: "https://search.example/?query=caplets", + $referring_domain: "search.example", + $title: "Private search title", + $set: { email: "person@example.com" }, + $set_once: { plan: "team" }, + $unset: ["person_property"], + $user_id: "known-user", + $device_id: "anonymous-browser-identity", + $session_id: "session-identity", + $lib: "web", + $lib_version: "captured-sdk-version", + $future_sdk_property: "not implicitly allowed", + unknown_application_property: "not allowed", + }, + $set: { email: "person@example.com" }, + $set_once: { plan: "team" }, + $unset: ["person_property"], + arbitrary_top_level: "not allowed", + }; +} + +function sanitizedCatalogSearch() { + return { + uuid: "0189d14f-4f1a-7000-8000-000000000001", + event: "caplets_catalog_search", + timestamp: new Date("2026-07-10T12:00:00.000Z"), + properties: { + token: "phc_public_project_token", + distinct_id: "anonymous-browser-identity", + surface: "catalog", + route_family: "catalog", + page_family: "catalog", + section_category: "search", + search_length_bucket: "medium", + filter_category: "tag", + result_count_bucket: "few", + empty_state_category: "unknown", + $process_person_profile: false, + $geoip_disable: true, + }, + }; +} + +describe("PostHog final transport contract", () => { + beforeEach(() => { + transportFetch.mockReset(); + transportFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + browserStorage.clear(); + vi.stubGlobal("localStorage", browserStorage); + vi.stubGlobal("sessionStorage", browserStorage); + vi.stubGlobal("fetch", transportFetch); + vi.stubGlobal("XMLHttpRequest", undefined); + Object.defineProperty(window, "fetch", { configurable: true, value: transportFetch }); + Object.defineProperty(window, "localStorage", { configurable: true, value: browserStorage }); + Object.defineProperty(window, "sessionStorage", { configurable: true, value: browserStorage }); + Object.defineProperty(window, "XMLHttpRequest", { configurable: true, value: undefined }); + }); + + afterEach(() => { + browserStorage.clear(); + vi.unstubAllGlobals(); + }); + + it("reconstructs the exact allowed final CaptureResult envelope", () => { + expect(sanitizePostHogCapture(posthog13950CaptureResult())).toEqual(sanitizedCatalogSearch()); + }); + it("serializes an unmocked named SDK instance through the final hook without a flags request", async () => { + // The SDK snapshots browser transports at module evaluation, so import it after interception. + const { default: posthog } = await import("../../../apps/landing/node_modules/posthog-js"); + const receivedByHook: unknown[] = []; + const beforeSend = createPostHogBeforeSend(); + + const instance = posthog.init( + "phc_public_project_token", + { + api_host: "https://posthog.transport.test", + advanced_disable_flags: true, + autocapture: false, + capture_pageview: false, + capture_pageleave: false, + capture_performance: false, + disable_compression: true, + disable_persistence: true, + disable_session_recording: true, + disable_surveys: true, + disable_web_experiments: true, + opt_out_useragent_filter: true, + person_profiles: "never", + persistence: "memory", + request_batching: false, + save_campaign_params: false, + save_referrer: false, + before_send(payload: unknown) { + receivedByHook.push(payload); + return beforeSend(payload); + }, + }, + "u6_posthog_provider_contract", + ); + expect(instance.__loaded).toBe(true); + expect(instance.config.request_batching).toBe(false); + expect(instance.has_opted_out_capturing()).toBe(false); + instance.identify("known-user"); + expect(instance.get_distinct_id()).not.toBe("known-user"); + + const event = buildWebEvent({ + name: "caplets_catalog_search", + properties: { + surface: "catalog", + route_family: "catalog", + page_family: "catalog", + section_category: "search", + search_length_bucket: "medium", + filter_category: "tag", + result_count_bucket: "few", + empty_state_category: "unknown", + }, + }); + + instance.capture( + event.name, + { + ...event.properties, + $current_url: "https://catalog.caplets.dev/caplets?query=secret", + $set: { email: "person@example.com" }, + unknown_application_property: "not allowed", + }, + { $set: { email: "person@example.com" } }, + ); + + await vi.waitFor(() => expect(receivedByHook).toHaveLength(1)); + + await vi.waitFor(() => expect(transportFetch).toHaveBeenCalled()); + + const request = transportFetch.mock.calls.find(([url]) => String(url).includes("/e/")); + const requestUrls = transportFetch.mock.calls.map(([url]) => String(url)); + const augmentedProperties = ( + receivedByHook.at(-1) as { properties?: Record } | undefined + )?.properties; + expect(request).toBeDefined(); + expect(requestUrls.some((url) => url.includes("/flags"))).toBe(false); + expect(augmentedProperties).toMatchObject({ $is_identified: false }); + expect(augmentedProperties?.distinct_id).toBe(augmentedProperties?.$device_id); + + const body = JSON.parse(String(request?.[1]?.body)) as { + event: string; + properties: Record; + uuid: string; + }; + expect(body.event).toBe(event.name); + expect(body.uuid).toEqual(expect.any(String)); + expect(body.properties).toMatchObject({ + token: "phc_public_project_token", + distinct_id: expect.any(String), + $process_person_profile: false, + $geoip_disable: true, + }); + expect(body.properties.distinct_id).not.toBe("known-user"); + expect(body.properties).not.toHaveProperty("$current_url"); + expect(body.properties).not.toHaveProperty("$set"); + expect(body.properties).not.toHaveProperty("$device_id"); + expect(body.properties).not.toHaveProperty("unknown_application_property"); + }); + + it.each([ + ["null", null], + ["an array", []], + ["a non-string uuid", { ...posthog13950CaptureResult(), uuid: 1 }], + ["an empty uuid", { ...posthog13950CaptureResult(), uuid: "" }], + ["a non-object properties value", { ...posthog13950CaptureResult(), properties: [] }], + [ + "a missing provider token", + { + ...posthog13950CaptureResult(), + properties: { ...posthog13950CaptureResult().properties, token: "" }, + }, + ], + [ + "a missing anonymous transport identity", + { + ...posthog13950CaptureResult(), + properties: { ...posthog13950CaptureResult().properties, distinct_id: "" }, + }, + ], + ["an invalid timestamp", { ...posthog13950CaptureResult(), timestamp: "not-a-date" }], + ])("drops malformed payloads without throwing: %s", (_label, payload) => { + expect(() => sanitizePostHogCapture(payload)).not.toThrow(); + expect(sanitizePostHogCapture(payload)).toBeNull(); + }); + + it("fails closed on arbitrary or URL-shaped event names", () => { + const rawUrlEvent = posthog13950CaptureResult(); + rawUrlEvent.event = "https://private.example/path?token=secret"; + const arbitraryEvent = posthog13950CaptureResult(); + arbitraryEvent.event = "unapproved_event"; + + expect(sanitizePostHogCapture(rawUrlEvent)).toBeNull(); + expect(sanitizePostHogCapture(arbitraryEvent)).toBeNull(); + }); + + it("drops identified, unverified, or device-mismatched final envelopes", () => { + const identified = posthog13950CaptureResult(); + identified.properties.$is_identified = true; + const unverified = posthog13950CaptureResult(); + delete unverified.properties.$is_identified; + const malformedIdentificationState = posthog13950CaptureResult(); + malformedIdentificationState.properties.$is_identified = "false"; + const deviceMismatch = posthog13950CaptureResult(); + deviceMismatch.properties.$device_id = "different-device-identity"; + + expect(sanitizePostHogCapture(identified)).toBeNull(); + expect(sanitizePostHogCapture(unverified)).toBeNull(); + expect(sanitizePostHogCapture(malformedIdentificationState)).toBeNull(); + expect(sanitizePostHogCapture(deviceMismatch)).toBeNull(); + }); + + it("forces profile and GeoIP controls across malformed incoming flag values", () => { + for (const processPersonProfile of [undefined, true, "false", 0, {}]) { + const payload = posthog13950CaptureResult(); + payload.properties.$process_person_profile = processPersonProfile; + const sanitized = sanitizePostHogCapture(payload); + expect(sanitized?.properties.$process_person_profile).toBe(false); + } + + for (const geoipDisable of [undefined, false, "true", 1, []]) { + const payload = posthog13950CaptureResult(); + payload.properties.$geoip_disable = geoipDisable; + const sanitized = sanitizePostHogCapture(payload); + expect(sanitized?.properties.$geoip_disable).toBe(true); + } + }); + + it("omits unknown SDK fields and categorical values with nested shapes", () => { + const payload = posthog13950CaptureResult(); + payload.properties.scroll_depth_bucket = ["gte_75"]; + payload.properties.unknown_object = { source: "raw" }; + payload.properties.$future_sdk_property = { raw: true }; + + const sanitized = sanitizePostHogCapture(payload); + + expect(sanitized).toEqual(sanitizedCatalogSearch()); + expect(sanitized?.properties).not.toHaveProperty("scroll_depth_bucket"); + expect(sanitized?.properties).not.toHaveProperty("unknown_object"); + expect(sanitized?.properties).not.toHaveProperty("$future_sdk_property"); + }); + + it("drops an event when a required categorical property has a nested shape", () => { + const payload = posthog13950CaptureResult(); + payload.properties.surface = ["catalog"]; + + expect(sanitizePostHogCapture(payload)).toBeNull(); + }); + + it("contains injected final-hook failures and app-supplied capture failures", () => { + const beforeSend = createPostHogBeforeSend(() => { + throw new Error("sanitizer failure"); + }); + const event = buildWebEvent({ + name: "caplets_site_pageview", + properties: { + surface: "landing", + route_family: "home", + page_family: "home", + referrer_category: "direct", + }, + }); + const received: Array<[string, unknown]> = []; + + expect(beforeSend(posthog13950CaptureResult())).toBeNull(); + capturePostHogEvent( + { + capture(name, properties) { + received.push([name, properties]); + }, + }, + event, + ); + expect(received).toEqual([[event.name, event.properties]]); + expect(() => + capturePostHogEvent( + { + capture() { + throw new Error("capture failure"); + }, + }, + event, + ), + ).not.toThrow(); + }); +}); diff --git a/packages/web-observability/test/web-observability.test.ts b/packages/web-observability/test/web-observability.test.ts index c7b35243..c458aad1 100644 --- a/packages/web-observability/test/web-observability.test.ts +++ b/packages/web-observability/test/web-observability.test.ts @@ -6,7 +6,6 @@ import { bucketResultCount, bucketSearchTerm, classifyRouteFamily, - filterPostHogProperties, filterSentryBrowserEvent, } from "../src/index"; @@ -134,18 +133,6 @@ describe("web observability contract", () => { ).toBe("caplets telemetry attribution docs_install\ncaplets setup"); }); - it("filters PostHog SDK properties down to allowed categories", () => { - expect( - filterPostHogProperties({ - surface: "landing", - route_family: "home", - $current_url: "https://caplets.ai/", - distinct_id: "visitor-123", - token: "secret", - }), - ).toEqual({ surface: "landing", route_family: "home" }); - }); - it("filters Sentry browser events without raw messages, urls, user, request, or extra payloads", () => { expect( filterSentryBrowserEvent({ From 3e58bd2596638b69378432ed3100f6eeac86086a Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 10 Jul 2026 08:03:10 -0400 Subject: [PATCH 7/9] refactor(core): centralize Project Binding lifecycle Own accepted Caplet IDs, serialized updates, cleanup-last close, and atomic replacement behind one native lifecycle while preserving distinct Cloud and self-hosted failure policies. Reauthorize and serialize self-hosted lease mutations so stale sockets cannot revive terminal bindings. --- .changeset/calm-media-seam.md | 2 + docs/architecture.md | 4 + packages/core/src/cloud/client.ts | 26 +- packages/core/src/cloud/presence.ts | 219 ++- .../src/native/project-binding-lifecycle.ts | 368 ++++ packages/core/src/native/service.ts | 426 +++-- .../core/src/project-binding/workspaces.ts | 67 +- packages/core/src/serve/http.ts | 530 ++++-- packages/core/test/cloud-presence.test.ts | 294 ++- packages/core/test/native-remote.test.ts | 1112 +++++++++++- packages/core/test/native.test.ts | 38 +- .../test/project-binding-integration.test.ts | 146 ++ packages/core/test/serve-http.test.ts | 1596 +++++++++++++++-- 13 files changed, 4331 insertions(+), 497 deletions(-) create mode 100644 packages/core/src/native/project-binding-lifecycle.ts diff --git a/.changeset/calm-media-seam.md b/.changeset/calm-media-seam.md index e57c7087..1d6bb25e 100644 --- a/.changeset/calm-media-seam.md +++ b/.changeset/calm-media-seam.md @@ -12,3 +12,5 @@ Replace `handleServerTool`'s positional manager arguments with a named backend r Make exposure projection the generation-bound callable-surface authority. MCP, Attach, and native adapters now render registration facts and Code Mode identities from the same projection, reject stale callbacks across reloads, discard out-of-order discovery, and keep hidden or unresolved Caplets out of declarations and execution allowlists. Concentrate Current Host administration behind one typed operations Interface shared by dashboard and Operator bearer adapters. Operator activity now records the real acting Client, exact Access and Operator route roles are enforced, both Client roles can revoke only their own credential, and self-revocation or demotion ends the acting dashboard session. Raw Vault Reveal remains dashboard-only and expires from browser memory. + +Give native Cloud and self-hosted Project Binding one lifecycle owner for accepted Caplet IDs, serialized updates, cleanup-last close, and atomic remote replacement while preserving their distinct failure policies. Self-hosted sockets now reauthorize durable Access Clients at execution time and serialize heartbeat, expiry, prune, end, and shutdown state so stale work cannot revive a terminal lease. diff --git a/docs/architecture.md b/docs/architecture.md index d82cbaba..24a88e40 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,6 +118,10 @@ Remote control under `packages/core/src/remote-control/` lets CLI and native int Project Binding under `packages/core/src/project-binding/` connects a local project root to a remote runtime. The foreground attach loop owns session state, heartbeat, reconnect behavior, sync preflight, and terminal recovery commands. +Native Project Binding lifecycle ordering lives in `packages/core/src/native/project-binding-lifecycle.ts`. The owner retains the last accepted local allowed-Caplet set, serializes and coalesces remote updates, makes cleanup the final mutation, and commits remote replacement only after the previous Adapter cleans up. Cloud and self-hosted remain distinct Adapters: Cloud heartbeat failures report without re-registration, while self-hosted failures disconnect and permit a later registration attempt. + +Self-hosted Binding Session records serialize heartbeat, end, expiry, prune, and shutdown mutations per record. Active socket work reauthorizes the durable Client ID at execution time, stages lease writes, and commits only after authorization, record generation, identity, and expiry remain current; terminal cleanup prevents stale or second-socket work from resurrecting a lease. + `docs/project-binding.md` is the living operational contract for Project Binding. ## Backend Contracts diff --git a/packages/core/src/cloud/client.ts b/packages/core/src/cloud/client.ts index 6b0dd025..dd26d92d 100644 --- a/packages/core/src/cloud/client.ts +++ b/packages/core/src/cloud/client.ts @@ -15,6 +15,10 @@ export type RegisterPresenceInput = { fallbackConsent?: "allow" | "deny" | undefined; }; +export type PresenceRequestOptions = { + signal?: AbortSignal; +}; + export type RegisterPresenceResult = { presenceId: string; expiresAt: string; @@ -76,7 +80,10 @@ export class CapletsCloudClient { this.fetchImpl = options.fetch ?? fetch; } - async registerPresence(input: RegisterPresenceInput): Promise { + async registerPresence( + input: RegisterPresenceInput, + options: PresenceRequestOptions = {}, + ): Promise { const response = await this.fetchImpl(this.endpoint("api/project-bindings"), { method: "POST", headers: this.headers({ json: true }), @@ -90,6 +97,7 @@ export class CapletsCloudClient { fallbackConsent: input.fallbackConsent ?? "deny", projectFiles: input.projectFiles ?? [], }), + ...(options.signal ? { signal: options.signal } : {}), }); if (!response.ok) { throw new Error(`Caplets Cloud Project Binding registration failed: HTTP ${response.status}`); @@ -101,13 +109,14 @@ export class CapletsCloudClient { }; } - async stopPresence(presenceId: string): Promise { + async stopPresence(presenceId: string, options: PresenceRequestOptions = {}): Promise { const response = await this.fetchImpl( this.endpoint(`api/project-bindings/${encodeURIComponent(presenceId)}`), { method: "PATCH", headers: this.headers({ json: true }), body: JSON.stringify({ state: "offline" }), + ...(options.signal ? { signal: options.signal } : {}), }, ); if (!response.ok && response.status !== 404) { @@ -115,13 +124,17 @@ export class CapletsCloudClient { } } - async heartbeatPresence(presenceId: string): Promise { + async heartbeatPresence( + presenceId: string, + options: PresenceRequestOptions = {}, + ): Promise { const response = await this.fetchImpl( this.endpoint(`api/project-bindings/${encodeURIComponent(presenceId)}`), { method: "PATCH", headers: this.headers({ json: true }), body: JSON.stringify({ state: "ready", syncState: "idle" }), + ...(options.signal ? { signal: options.signal } : {}), }, ); if (!response.ok) { @@ -133,13 +146,18 @@ export class CapletsCloudClient { }; } - async updatePresenceCaplets(presenceId: string, allowedCapletIds: string[]): Promise { + async updatePresenceCaplets( + presenceId: string, + allowedCapletIds: string[], + options: PresenceRequestOptions = {}, + ): Promise { const response = await this.fetchImpl( this.endpoint(`api/project-bindings/${encodeURIComponent(presenceId)}`), { method: "PATCH", headers: this.headers({ json: true }), body: JSON.stringify({ allowedCapletIds }), + ...(options.signal ? { signal: options.signal } : {}), }, ); if (!response.ok) { diff --git a/packages/core/src/cloud/presence.ts b/packages/core/src/cloud/presence.ts index 3dfa096e..99e86c4d 100644 --- a/packages/core/src/cloud/presence.ts +++ b/packages/core/src/cloud/presence.ts @@ -1,77 +1,199 @@ -import type { CapletsCloudClient, RegisterPresenceInput } from "./client"; +import type { CapletsCloudClient, PresenceRequestOptions, RegisterPresenceInput } from "./client"; +import { + withProjectBindingMutationDeadline, + type ProjectBindingMutationDeadlineOptions, + type ProjectBindingSessionAdapter, +} from "../native/project-binding-lifecycle"; type PresenceClient = Pick & { - heartbeatPresence?: (presenceId: string) => Promise; - stopPresence?: (presenceId: string) => Promise; - updatePresenceCaplets?: (presenceId: string, allowedCapletIds: string[]) => Promise; + heartbeatPresence?: (presenceId: string, options?: PresenceRequestOptions) => Promise; + stopPresence?: (presenceId: string, options?: PresenceRequestOptions) => Promise; + updatePresenceCaplets?: ( + presenceId: string, + allowedCapletIds: string[], + options?: PresenceRequestOptions, + ) => Promise; }; -export type ProjectBindingSessionManagerOptions = RegisterPresenceInput & { - client: PresenceClient; - heartbeatIntervalMs?: number; - setInterval?: typeof setInterval; - clearInterval?: typeof clearInterval; - onError?: (error: unknown) => void; -}; +export type ProjectBindingSessionManagerOptions = RegisterPresenceInput & + ProjectBindingMutationDeadlineOptions & { + client: PresenceClient; + heartbeatIntervalMs?: number; + setInterval?: typeof setInterval; + clearInterval?: typeof clearInterval; + onError?: (error: unknown) => void; + }; -export class ProjectBindingSessionManager { +export class ProjectBindingSessionManager implements ProjectBindingSessionAdapter { private presenceId: string | undefined; + private allowedCapletIds: string[]; private heartbeatTimer: ReturnType | undefined; - private startPromise: Promise | undefined; + private mutationChain: Promise = Promise.resolve(); + private startInFlight: Promise | undefined; + private closeInFlight: Promise | undefined; + private preparingClose = false; + private closing = false; + private closed = false; + private mutationSequence = 0; + private closeMutationBoundary = 0; + private timerHeartbeatQueued = false; + + constructor(private readonly options: ProjectBindingSessionManagerOptions) { + this.allowedCapletIds = [...options.allowedCapletIds]; + } - constructor(private readonly options: ProjectBindingSessionManagerOptions) {} + hasActiveSession(): boolean { + return this.presenceId !== undefined; + } - async start(): Promise { - if (this.startPromise) { - return await this.startPromise; + async start(allowedCapletIds = this.allowedCapletIds): Promise { + this.allowedCapletIds = [...allowedCapletIds]; + if (this.closed || this.closing || this.presenceId) return false; + if (this.startInFlight) { + return await this.startInFlight; + } + + const mutationSequence = ++this.mutationSequence; + const start = this.enqueue(async (): Promise => { + if ( + this.closed || + this.presenceId || + (this.closing && mutationSequence > this.closeMutationBoundary) + ) { + return false; + } + const result = await withProjectBindingMutationDeadline( + (signal) => + this.options.client.registerPresence( + { + workspaceId: this.options.workspaceId, + projectRoot: this.options.projectRoot, + projectFingerprint: this.options.projectFingerprint, + allowedCapletIds: this.allowedCapletIds, + projectFiles: this.options.projectFiles, + fallbackConsent: this.options.fallbackConsent ?? "deny", + }, + { signal }, + ), + this.options, + ); + this.presenceId = result.presenceId; + if (!this.preparingClose) this.startHeartbeat(); + return true; + }); + this.startInFlight = start; + try { + return await start; + } finally { + if (this.startInFlight === start) this.startInFlight = undefined; } - this.startPromise = this.register(); - return await this.startPromise; } - private async register(): Promise { - const result = await this.options.client.registerPresence({ - workspaceId: this.options.workspaceId, - projectRoot: this.options.projectRoot, - projectFingerprint: this.options.projectFingerprint, - allowedCapletIds: this.options.allowedCapletIds, - projectFiles: this.options.projectFiles, - fallbackConsent: this.options.fallbackConsent ?? "deny", + async updateAllowedCapletIds(allowedCapletIds: string[]): Promise { + this.allowedCapletIds = [...allowedCapletIds]; + if (this.closed || this.closing) return; + const mutationSequence = ++this.mutationSequence; + await this.enqueue(async () => { + if (this.closed || (this.closing && mutationSequence > this.closeMutationBoundary)) return; + const presenceId = this.presenceId; + if (!presenceId || !this.options.client.updatePresenceCaplets) return; + await withProjectBindingMutationDeadline( + (signal) => + this.options.client.updatePresenceCaplets?.(presenceId, this.allowedCapletIds, { + signal, + }) ?? Promise.resolve(), + this.options, + ); }); - this.presenceId = result.presenceId; - this.startHeartbeat(); } - async close(): Promise { - await this.startPromise?.catch(() => undefined); - const presenceId = this.presenceId; - this.presenceId = undefined; + prepareClose(): void { + this.preparingClose = true; this.stopHeartbeat(); - if (presenceId && this.options.client.stopPresence) { - await this.options.client.stopPresence(presenceId); - } } - async updateAllowedCapletIds(allowedCapletIds: string[]): Promise { - await this.startPromise?.catch(() => undefined); - const presenceId = this.presenceId; - if (!presenceId || !this.options.client.updatePresenceCaplets) { + async close(): Promise { + if (this.closed) return; + if (this.closeInFlight) { + await this.closeInFlight; return; } - await this.options.client.updatePresenceCaplets(presenceId, allowedCapletIds); + + this.prepareClose(); + this.closeMutationBoundary = this.mutationSequence; + this.closing = true; + const close = this.enqueue(async () => { + const presenceId = this.presenceId; + if (presenceId && this.options.client.stopPresence) { + await withProjectBindingMutationDeadline( + (signal) => + this.options.client.stopPresence?.(presenceId, { signal }) ?? Promise.resolve(), + this.options, + ); + } + this.presenceId = undefined; + this.closed = true; + }); + this.closeInFlight = close; + try { + await close; + } finally { + if (this.closeInFlight === close && !this.closed) this.closeInFlight = undefined; + } + } + + dispose(): void { + this.preparingClose = true; + this.closing = true; + this.closed = true; + this.presenceId = undefined; + this.stopHeartbeat(); + } + + private enqueue(mutation: () => Promise): Promise { + const next = this.mutationChain.then(mutation, mutation); + this.mutationChain = next.then( + () => undefined, + () => undefined, + ); + return next; } private startHeartbeat(): void { - if (!this.options.client.heartbeatPresence || this.options.heartbeatIntervalMs === undefined) { + if ( + this.preparingClose || + !this.options.client.heartbeatPresence || + this.options.heartbeatIntervalMs === undefined + ) { return; } const setIntervalImpl = this.options.setInterval ?? setInterval; this.heartbeatTimer = setIntervalImpl(() => { - const presenceId = this.presenceId; - if (!presenceId || !this.options.client.heartbeatPresence) return; - void this.options.client.heartbeatPresence(presenceId).catch((error) => { - this.options.onError?.(error); - }); + if (this.timerHeartbeatQueued) return; + this.timerHeartbeatQueued = true; + const mutationSequence = ++this.mutationSequence; + void this.enqueue(async () => { + if ( + this.preparingClose || + this.closed || + (this.closing && mutationSequence > this.closeMutationBoundary) + ) { + return; + } + const presenceId = this.presenceId; + if (!presenceId || !this.options.client.heartbeatPresence) return; + await withProjectBindingMutationDeadline( + (signal) => + this.options.client.heartbeatPresence?.(presenceId, { signal }) ?? Promise.resolve(), + this.options, + ); + }) + .catch((error) => { + this.options.onError?.(error); + }) + .finally(() => { + this.timerHeartbeatQueued = false; + }); }, this.options.heartbeatIntervalMs); } @@ -83,6 +205,3 @@ export class ProjectBindingSessionManager { } } } - -export type LocalPresenceManagerOptions = ProjectBindingSessionManagerOptions; -export const LocalPresenceManager = ProjectBindingSessionManager; diff --git a/packages/core/src/native/project-binding-lifecycle.ts b/packages/core/src/native/project-binding-lifecycle.ts new file mode 100644 index 00000000..476cc738 --- /dev/null +++ b/packages/core/src/native/project-binding-lifecycle.ts @@ -0,0 +1,368 @@ +export type ProjectBindingSessionAdapter = { + start(allowedCapletIds: string[]): Promise; + updateAllowedCapletIds(allowedCapletIds: string[]): Promise; + hasActiveSession?(): boolean; + prepareClose?(): void; + close(): Promise; + dispose?(): void; +}; + +export interface ProjectBindingLifecycle { + start(): Promise; + updateAllowedCapletIds(allowedCapletIds: string[]): Promise; + replace( + adapter: ProjectBindingSessionAdapter | undefined, + beforeStart?: (() => Promise) | undefined, + ): Promise; + isCleanupFailed(): boolean; + close(): Promise; +} + +export const PROJECT_BINDING_MUTATION_TIMEOUT_MS = 10_000; + +export type ProjectBindingMutationDeadlineOptions = { + mutationTimeoutMs?: number; + setTimeout?: typeof setTimeout; + clearTimeout?: typeof clearTimeout; +}; + +export function withProjectBindingMutationDeadline( + operation: (signal: AbortSignal) => Promise, + options: ProjectBindingMutationDeadlineOptions = {}, +): Promise { + const timeoutMs = options.mutationTimeoutMs ?? PROJECT_BINDING_MUTATION_TIMEOUT_MS; + const controller = new AbortController(); + return new Promise((resolve, reject) => { + const clearTimeoutImpl = options.clearTimeout ?? clearTimeout; + const timeout = (options.setTimeout ?? setTimeout)(() => { + clearTimeoutImpl(timeout); + controller.abort(); + reject(new Error(`Project Binding mutation timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + void Promise.resolve() + .then(() => operation(controller.signal)) + .then( + (value) => { + clearTimeoutImpl(timeout); + resolve(value); + }, + (error: unknown) => { + clearTimeoutImpl(timeout); + reject(error); + }, + ); + }); +} + +export class NativeProjectBindingLifecycle implements ProjectBindingLifecycle { + private adapter: ProjectBindingSessionAdapter | undefined; + private acceptedAllowedCapletIds: string[]; + private activeAllowedCapletIds: string[]; + private sentAllowedCapletIds: string[] | undefined; + private started = false; + private closing = false; + private closed = false; + private replacing = false; + private cleanupFailed = false; + private startInFlight: Promise | undefined; + private updateInFlight: Promise | undefined; + private closeInFlight: Promise | undefined; + private replacementInFlight: Promise | undefined; + + constructor(adapter: ProjectBindingSessionAdapter | undefined, allowedCapletIds: string[]) { + this.adapter = adapter; + this.acceptedAllowedCapletIds = normalizeAllowedCapletIds(allowedCapletIds); + this.activeAllowedCapletIds = [...this.acceptedAllowedCapletIds]; + } + + async start(): Promise { + if (!this.adapter || this.closing || this.closed || this.replacing || this.cleanupFailed) { + return; + } + if (this.startInFlight) { + await this.startInFlight; + return; + } + const adapter = this.adapter; + const start = this.startAdapter(adapter, false); + this.startInFlight = start; + try { + await start; + } finally { + if (this.startInFlight === start) this.startInFlight = undefined; + } + } + + async updateAllowedCapletIds(allowedCapletIds: string[]): Promise { + const normalized = normalizeAllowedCapletIds(allowedCapletIds); + if (sameAllowedCapletIds(normalized, this.acceptedAllowedCapletIds)) return; + if (this.closing || this.closed || this.cleanupFailed) return; + + this.acceptedAllowedCapletIds = normalized; + if (!this.replacing) { + this.activeAllowedCapletIds = [...normalized]; + } + if (!this.started || this.replacing) return; + await this.flushAllowedCapletIds(); + } + + async replace( + adapter: ProjectBindingSessionAdapter | undefined, + beforeStart?: (() => Promise) | undefined, + ): Promise { + if (this.closed || this.closing) { + adapter?.dispose?.(); + return; + } + if (this.replacementInFlight) { + adapter?.dispose?.(); + await this.replacementInFlight; + return; + } + + const previous = this.adapter; + this.replacing = true; + previous?.prepareClose?.(); + const replacement = this.replaceAdapter(previous, adapter, beforeStart); + this.replacementInFlight = replacement; + try { + await replacement; + } finally { + if (this.replacementInFlight === replacement) { + this.replacementInFlight = undefined; + this.replacing = false; + } + } + } + + isCleanupFailed(): boolean { + return this.cleanupFailed; + } + async close(): Promise { + if (this.closed) return; + if (this.closeInFlight) { + await this.closeInFlight; + return; + } + + this.closing = true; + this.adapter?.prepareClose?.(); + const close = this.closeAdapter(); + this.closeInFlight = close; + try { + await close; + } catch (error) { + this.cleanupFailed = true; + throw error; + } finally { + if (this.closeInFlight === close && !this.closed) this.closeInFlight = undefined; + } + } + + private async startAdapter( + adapter: ProjectBindingSessionAdapter, + allowReplacement: boolean, + ): Promise { + // Yield once so an accepted source update in the construction turn seeds registration. + await Promise.resolve(); + if ( + this.adapter !== adapter || + this.closed || + this.closing || + (!allowReplacement && this.replacing) + ) { + return; + } + + const allowedCapletIds = [...this.activeAllowedCapletIds]; + const wasStarted = this.started; + const registered = await adapter.start(allowedCapletIds); + if (this.adapter !== adapter) return; + if (registered === false && adapter.hasActiveSession?.() === false) { + this.started = false; + return; + } + + this.started = true; + if (!wasStarted || registered === true) { + this.sentAllowedCapletIds = allowedCapletIds; + } + if (!this.closing && !this.replacing) { + await this.flushAllowedCapletIds(); + } + } + + private async flushAllowedCapletIds(): Promise { + if (!this.adapter || !this.started || this.closed || this.cleanupFailed) return; + if (this.updateInFlight) { + await this.updateInFlight; + return; + } + + const adapter = this.adapter; + let lastAttempted: string[] | undefined; + const update = (async () => { + while ( + this.adapter === adapter && + this.started && + !this.closed && + !this.cleanupFailed && + !sameAllowedCapletIds(this.sentAllowedCapletIds, this.activeAllowedCapletIds) + ) { + const allowedCapletIds = [...this.activeAllowedCapletIds]; + if (adapter.hasActiveSession?.() === false) { + const registered = await adapter.start(allowedCapletIds); + if (this.adapter !== adapter) return; + if (registered !== true) { + this.started = false; + return; + } + this.sentAllowedCapletIds = allowedCapletIds; + continue; + } + lastAttempted = allowedCapletIds; + await adapter.updateAllowedCapletIds(allowedCapletIds); + if (this.adapter !== adapter) return; + this.sentAllowedCapletIds = allowedCapletIds; + } + })(); + this.updateInFlight = update; + let retryLatestAllowedCapletIds = false; + try { + await update; + } catch (error) { + retryLatestAllowedCapletIds = + lastAttempted !== undefined && + !sameAllowedCapletIds(lastAttempted, this.activeAllowedCapletIds); + throw error; + } finally { + if (this.updateInFlight === update) this.updateInFlight = undefined; + if ( + retryLatestAllowedCapletIds && + this.adapter === adapter && + this.started && + !this.closed && + !this.cleanupFailed + ) { + void this.flushAllowedCapletIds().catch(() => undefined); + } + } + } + + private async flushAcceptedAllowedCapletIds(): Promise { + while (this.adapter && this.started && !this.closed && !this.cleanupFailed) { + this.activeAllowedCapletIds = [...this.acceptedAllowedCapletIds]; + await this.flushAllowedCapletIds(); + if (sameAllowedCapletIds(this.activeAllowedCapletIds, this.acceptedAllowedCapletIds)) return; + } + } + + private async replaceAdapter( + previous: ProjectBindingSessionAdapter | undefined, + candidate: ProjectBindingSessionAdapter | undefined, + beforeStart: (() => Promise) | undefined, + ): Promise { + await this.startInFlight?.catch(() => undefined); + if (previous && this.started) { + await this.flushAllowedCapletIds().catch(() => undefined); + } + + if (this.closed) { + candidate?.dispose?.(); + return; + } + + if (previous && (this.started || previous.hasActiveSession?.())) { + try { + await previous.close(); + } catch (error) { + this.cleanupFailed = true; + candidate?.dispose?.(); + throw error; + } + previous.dispose?.(); + } else { + previous?.dispose?.(); + } + if (this.adapter === previous) { + this.adapter = undefined; + } + this.started = false; + this.sentAllowedCapletIds = undefined; + this.cleanupFailed = false; + if (this.closing) { + candidate?.dispose?.(); + this.closed = true; + return; + } + try { + await beforeStart?.(); + } catch (error) { + candidate?.dispose?.(); + throw error; + } + this.adapter = candidate; + this.activeAllowedCapletIds = [...this.acceptedAllowedCapletIds]; + if (!candidate) return; + + try { + await this.startAdapter(candidate, true); + } catch (error) { + candidate.dispose?.(); + if (this.adapter === candidate) this.adapter = undefined; + this.started = false; + this.sentAllowedCapletIds = undefined; + throw error; + } + await this.flushAcceptedAllowedCapletIds(); + if (!this.closing) return; + + candidate.prepareClose?.(); + if (this.started) { + await candidate.close(); + } + candidate.dispose?.(); + if (this.adapter === candidate) this.adapter = undefined; + this.started = false; + this.sentAllowedCapletIds = undefined; + this.closed = true; + } + + private async closeAdapter(): Promise { + const replacement = this.replacementInFlight; + if (replacement) { + await replacement; + return; + } + + await this.startInFlight?.catch(() => undefined); + const adapter = this.adapter; + if (!adapter || (!this.started && !adapter.hasActiveSession?.())) { + adapter?.dispose?.(); + if (this.adapter === adapter) this.adapter = undefined; + this.closed = true; + return; + } + + await this.flushAllowedCapletIds().catch(() => undefined); + await adapter.close(); + adapter.dispose?.(); + if (this.adapter === adapter) this.adapter = undefined; + this.started = false; + this.sentAllowedCapletIds = undefined; + this.closed = true; + } +} + +function normalizeAllowedCapletIds(allowedCapletIds: string[]): string[] { + return [...new Set(allowedCapletIds)].sort(); +} + +function sameAllowedCapletIds( + left: readonly string[] | undefined, + right: readonly string[] | undefined, +): boolean { + if (left === right) return true; + if (!left || !right || left.length !== right.length) return false; + return left.every((value, index) => value === right[index]); +} diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index 05af5e25..da36a787 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -9,6 +9,12 @@ import { } from "./options"; import { CapletsCloudClient } from "../cloud/client"; import { ProjectBindingSessionManager } from "../cloud/presence"; +import { + NativeProjectBindingLifecycle, + withProjectBindingMutationDeadline, + type ProjectBindingLifecycle, + type ProjectBindingSessionAdapter, +} from "./project-binding-lifecycle"; import { projectSyncFiles } from "../cloud/sync"; import { findProjectRoot, fingerprintProjectRoot } from "../cloud/project-root"; import { @@ -213,7 +219,6 @@ class DefaultNativeCapletsService implements NativeCapletsService { }); this.postReloadRefresh = this.refreshExposureProjection({ emitToolsChanged: true }); this.unsubscribeEngineReload = this.engine.onReload(() => { - this.emitToolsChanged(); this.postReloadRefresh = this.refreshExposureProjection({ emitToolsChanged: true }); }); } @@ -293,7 +298,6 @@ class DefaultNativeCapletsService implements NativeCapletsService { async execute(capletId: string, request: unknown): Promise { this.assertResolvedProjection(); - this.listTools(); if (capletId === nativeCodeModeToolId) { if (this.codeModeNativeTools().length === 0) { throw new CapletsError("REQUEST_INVALID", "Code Mode has no callable Caplets."); @@ -800,11 +804,6 @@ type ResolvedNativeRemoteOptions = Extract< { remote: unknown } >["remote"]; -type NativeProjectBindingManager = Pick< - ProjectBindingSessionManager, - "start" | "close" | "updateAllowedCapletIds" ->; - function createLocalOverlayService(options: NativeCapletsServiceOptions): NativeCapletsService { const localOptions = { ...options, @@ -836,7 +835,7 @@ function createCompositeRemoteParts( options: NativeCapletsServiceOptions, authKind: "self_hosted_remote" | "hosted_cloud", resolveRuntimeRemoteOptions?: () => Promise, -): { remote: NativeCapletsService; presence?: NativeProjectBindingManager } { +): { remote: NativeCapletsService; presence?: ProjectBindingSessionAdapter } { const client = createRemoteClient(remoteOptions, options, authKind, resolveRuntimeRemoteOptions); const remote = new RemoteNativeCapletsService({ client, @@ -1023,10 +1022,16 @@ class ProfileBackedNativeCapletsService implements NativeCapletsService { () => this.resolveProfileRemoteOptions(), ); if (!(await remote.reload())) { - await Promise.all([remote.close(), presence?.close()]); + presence?.dispose?.(); + await remote.close(); return; } - await this.delegate.replaceRemote(remote, remoteOptions.url.toString(), presence); + const bindingReady = await this.delegate.replaceRemote( + remote, + remoteOptions.url.toString(), + presence, + ); + if (!bindingReady) return; this.remoteSignature = signature; this.credentialExpiresAt = remoteOptions.credentialExpiresAt; } catch (error) { @@ -1154,20 +1159,40 @@ class CompositeNativeCapletsService implements NativeCapletsService { private routes = new Map(); private namespaceDiagnostics = new Map(); private closed = false; + private closing = false; + private closeInFlight: Promise | undefined; private batchingReload = false; private readonly codeModeSessions = new CodeModeSessionManager(); private readonly telemetry: RuntimeTelemetryContext; private readonly ownsTelemetryDispatcher: boolean; + private projectBinding: ProjectBindingLifecycle | undefined; constructor( private remote: NativeCapletsService, private readonly local: NativeCapletsService, private readonly options: NativeCapletsServiceOptions, private remoteIdentity: string, - private presence?: NativeProjectBindingManager, + bindingAdapter?: ProjectBindingSessionAdapter, ) { + const initialLocalTools = this.local.listTools(); + if (bindingAdapter) { + this.projectBinding = new NativeProjectBindingLifecycle( + bindingAdapter, + initialLocalTools.map((tool) => tool.caplet), + ); + } this.unsubscribeRemote = this.remote.onToolsChanged(() => this.updateMergedTools()); - this.unsubscribeLocal = this.local.onToolsChanged(() => this.updateMergedTools()); + this.unsubscribeLocal = this.local.onToolsChanged((tools) => { + this.acceptLocalTools(tools); + void this.projectBinding?.start().catch((error) => { + if (isUnsupportedProjectBinding(error)) return; + writeErr( + this.options, + `Could not start upstream Project Binding: ${errorMessage(error)}\n`, + ); + }); + this.updateMergedTools(); + }); this.ownsTelemetryDispatcher = options.telemetryDispatcher === undefined; this.telemetry = createRuntimeTelemetryContext({ config: telemetryConfigFromNativeOptions(options), @@ -1184,7 +1209,15 @@ class CompositeNativeCapletsService implements NativeCapletsService { this.tools = merged.tools; this.routes = merged.routes; this.namespaceDiagnostics = merged.namespaceDiagnostics; - this.startPresence(); + if (initialLocalTools.length > 0) { + void this.projectBinding?.start().catch((error) => { + if (isUnsupportedProjectBinding(error)) return; + writeErr( + this.options, + `Could not start upstream Project Binding: ${errorMessage(error)}\n`, + ); + }); + } } listTools(): NativeCapletTool[] { @@ -1221,7 +1254,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { } async reload(): Promise { - if (this.closed) { + if (this.closed || this.closing) { return false; } this.batchingReload = true; @@ -1232,12 +1265,15 @@ class CompositeNativeCapletsService implements NativeCapletsService { return false; } if (localReloaded) { - await this.presence?.updateAllowedCapletIds( + await this.projectBinding?.updateAllowedCapletIds( this.local.listTools().map((tool) => tool.caplet), ); } this.telemetry.config = telemetryConfigFromNativeOptions(this.options); - this.startPresence(); + void this.projectBinding?.start().catch((error) => { + if (isUnsupportedProjectBinding(error)) return; + writeErr(this.options, `Could not start upstream Project Binding: ${errorMessage(error)}\n`); + }); this.updateMergedTools(); return remoteReloaded || localReloaded; } @@ -1248,46 +1284,124 @@ class CompositeNativeCapletsService implements NativeCapletsService { } async close(): Promise { - if (this.closed) { + if (this.closed) return; + if (this.closeInFlight) { + await this.closeInFlight; return; } - this.closed = true; + + this.closing = true; this.unsubscribeRemote(); this.unsubscribeLocal(); this.listeners.clear(); this.codeModeSessions.close(); - await Promise.all([ - this.remote.close(), - this.local.close(), - this.presence?.close(), - this.ownsTelemetryDispatcher ? this.telemetry.dispatcher.shutdown() : undefined, - ]); + const close = (async () => { + await this.projectBinding?.close(); + await this.remote.close(); + await this.local.close(); + if (this.ownsTelemetryDispatcher) { + await this.telemetry.dispatcher.shutdown(); + } + })(); + this.closeInFlight = close; + try { + await close; + this.closed = true; + } finally { + if (this.closeInFlight === close) this.closeInFlight = undefined; + } } async replaceRemote( remote: NativeCapletsService, - remoteIdentityOrPresence?: string | NativeProjectBindingManager, - presence?: NativeProjectBindingManager, - ): Promise { + remoteIdentityOrPresence?: string | ProjectBindingSessionAdapter, + presence?: ProjectBindingSessionAdapter, + ): Promise { const remoteIdentity = typeof remoteIdentityOrPresence === "string" ? remoteIdentityOrPresence : this.remoteIdentity; const nextPresence = typeof remoteIdentityOrPresence === "string" ? presence : remoteIdentityOrPresence; - if (this.closed) { - await Promise.all([remote.close(), nextPresence?.close()]); - return; + if (this.closed || this.closing) { + nextPresence?.dispose?.(); + await remote.close(); + return false; } + const previousRemote = this.remote; - const previousPresence = this.presence; - this.unsubscribeRemote(); + const previousUnsubscribe = this.unsubscribeRemote; + const hadProjectBinding = this.projectBinding !== undefined; + let previousRemoteClosed = false; + let terminalRemoteCloseFailure = false; + let bindingStartError: unknown; + const closePreviousRemote = async () => { + try { + await previousRemote.close(); + previousRemoteClosed = true; + } catch (error) { + terminalRemoteCloseFailure = previousRemote instanceof RemoteNativeCapletsService; + throw error; + } + }; + previousUnsubscribe(); + try { + if (this.projectBinding) { + await this.projectBinding.replace(nextPresence, closePreviousRemote); + } else { + await closePreviousRemote(); + if (this.closed || this.closing) { + nextPresence?.dispose?.(); + await remote.close(); + return false; + } + if (nextPresence) { + this.projectBinding = new NativeProjectBindingLifecycle( + nextPresence, + this.local.listTools().map((tool) => tool.caplet), + ); + } + } + } catch (error) { + const bindingCleanupFailed = hadProjectBinding && this.projectBinding?.isCleanupFailed(); + if ( + !previousRemoteClosed && + !terminalRemoteCloseFailure && + (!hadProjectBinding || bindingCleanupFailed) + ) { + if (!this.closed && !this.closing) { + this.unsubscribeRemote = previousRemote.onToolsChanged(() => this.updateMergedTools()); + } + if (!hadProjectBinding) nextPresence?.dispose?.(); + await remote.close().catch(() => undefined); + throw error; + } + bindingStartError = error; + } + + if (this.closed || this.closing) { + await remote.close(); + return false; + } this.remote = remote; this.remoteIdentity = remoteIdentity; - this.presence = nextPresence; this.unsubscribeRemote = this.remote.onToolsChanged(() => this.updateMergedTools()); - await Promise.all([previousRemote.close(), previousPresence?.close()]); - if (this.closed) return; - this.startPresence(); + if (bindingStartError) { + if (!isUnsupportedProjectBinding(bindingStartError)) { + writeErr( + this.options, + `Could not start upstream Project Binding: ${errorMessage(bindingStartError)}\n`, + ); + } + } else { + void this.projectBinding?.start().catch((error) => { + if (isUnsupportedProjectBinding(error)) return; + writeErr( + this.options, + `Could not start upstream Project Binding: ${errorMessage(error)}\n`, + ); + }); + } this.updateMergedTools(); + return bindingStartError === undefined || isUnsupportedProjectBinding(bindingStartError); } private updateMergedTools(): void { @@ -1415,11 +1529,15 @@ class CompositeNativeCapletsService implements NativeCapletsService { } } - private startPresence(): void { - void this.presence?.start().catch((error) => { - if (isUnsupportedProjectBinding(error)) return; - writeErr(this.options, `Could not start upstream Project Binding: ${errorMessage(error)}\n`); - }); + private acceptLocalTools(tools: NativeCapletTool[]): void { + void this.projectBinding + ?.updateAllowedCapletIds(tools.map((tool) => tool.caplet)) + .catch((error) => { + writeErr( + this.options, + `Could not update upstream Project Binding: ${errorMessage(error)}\n`, + ); + }); } } @@ -1469,7 +1587,7 @@ function createProjectBindingSessionManager( remoteOptions: ResolvedNativeRemoteOptions, local: NativeCapletsService, options: NativeCapletsServiceOptions, -): NativeProjectBindingManager | undefined { +): ProjectBindingSessionAdapter | undefined { const allowedCapletIds = local.listTools().map((tool) => tool.caplet); if (cloud) { const projectRoot = cloud.projectRoot ?? findProjectRoot(); @@ -1509,13 +1627,21 @@ function createProjectBindingSessionManager( }); } -class RemoteProjectBindingSessionManager implements NativeProjectBindingManager { +class RemoteProjectBindingSessionManager implements ProjectBindingSessionAdapter { private bindingId: string | undefined; private sessionId: string | undefined; private allowedCapletIds: string[]; private heartbeatTimer: ReturnType | undefined; - private startPromise: Promise | undefined; + private mutationChain: Promise = Promise.resolve(); + private startInFlight: Promise | undefined; + private closeInFlight: Promise | undefined; + private mutationSequence = 0; + private closeMutationBoundary = 0; private unsupported = false; + private preparingClose = false; + private closing = false; + private closed = false; + private timerHeartbeatQueued = false; constructor( private readonly options: { @@ -1531,77 +1657,160 @@ class RemoteProjectBindingSessionManager implements NativeProjectBindingManager this.allowedCapletIds = [...options.allowedCapletIds]; } - async start(): Promise { - if (this.unsupported) return; - if (!this.startPromise) { - const start = this.register(); - this.startPromise = start; - void start.catch((error) => { - if (isUnsupportedProjectBinding(error)) { - this.unsupported = true; - } - if (this.startPromise === start) { - this.startPromise = undefined; - } - }); - } - return await this.startPromise; + hasActiveSession(): boolean { + return this.bindingId !== undefined && this.sessionId !== undefined; } - async close(): Promise { - await this.startPromise?.catch(() => undefined); - this.stopHeartbeat(); - const bindingId = this.bindingId; - this.bindingId = undefined; - const sessionId = this.sessionId; - this.sessionId = undefined; - if (!bindingId || !sessionId) return; - await this.fetchJson(projectBindingUrl(this.options.attachUrl, bindingId, "session"), { - method: "DELETE", - body: { - sessionId, - terminalReason: { code: "completed", message: "Binding Session completed." }, - }, - }).catch(() => undefined); + async start(allowedCapletIds = this.allowedCapletIds): Promise { + this.allowedCapletIds = [...allowedCapletIds]; + if (this.unsupported || this.closed || this.closing || this.bindingId) return false; + if (this.startInFlight) { + return await this.startInFlight; + } + + const mutationSequence = ++this.mutationSequence; + const start = this.enqueue(async (): Promise => { + if ( + this.unsupported || + this.closed || + this.bindingId || + (this.closing && mutationSequence > this.closeMutationBoundary) + ) { + return false; + } + const projectFingerprint = fingerprintProjectRoot(this.options.projectRoot); + const response = await this.fetchJson<{ + binding?: { bindingId?: string | undefined }; + sessionId?: string | undefined; + }>(projectBindingUrl(this.options.attachUrl, "sessions"), { + method: "POST", + body: { + projectRoot: this.options.projectRoot, + projectFingerprint, + allowedCapletIds: this.allowedCapletIds, + }, + }); + if (!response.binding?.bindingId || !response.sessionId) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + "Project Binding session response was invalid.", + ); + } + this.bindingId = response.binding.bindingId; + this.sessionId = response.sessionId; + if (!this.preparingClose) this.startHeartbeat(); + return true; + }); + this.startInFlight = start; + void start.catch((error) => { + if (isUnsupportedProjectBinding(error)) this.unsupported = true; + }); + try { + return await start; + } finally { + if (this.startInFlight === start) this.startInFlight = undefined; + } } async updateAllowedCapletIds(allowedCapletIds: string[]): Promise { this.allowedCapletIds = [...allowedCapletIds]; - await this.startPromise?.catch(() => undefined); - if (!this.bindingId || !this.sessionId) return; - await this.heartbeat().catch(() => undefined); + if (this.closed || this.closing) return; + const mutationSequence = ++this.mutationSequence; + try { + await this.enqueue(async () => { + if (this.closed || (this.closing && mutationSequence > this.closeMutationBoundary)) return; + await this.heartbeat(); + }); + } catch (error) { + this.disconnect(); + this.options.writeErr?.(`Remote Project Binding heartbeat failed: ${errorMessage(error)}\n`); + throw error; + } } - private async register(): Promise { - const projectFingerprint = fingerprintProjectRoot(this.options.projectRoot); - const response = await this.fetchJson<{ - binding?: { bindingId?: string | undefined }; - sessionId?: string | undefined; - }>(projectBindingUrl(this.options.attachUrl, "sessions"), { - method: "POST", - body: { - projectRoot: this.options.projectRoot, - projectFingerprint, - allowedCapletIds: this.allowedCapletIds, - }, + prepareClose(): void { + this.preparingClose = true; + this.stopHeartbeat(); + } + + async close(): Promise { + if (this.closed) return; + if (this.closeInFlight) { + await this.closeInFlight; + return; + } + + this.prepareClose(); + this.closeMutationBoundary = this.mutationSequence; + this.closing = true; + const close = this.enqueue(async () => { + const bindingId = this.bindingId; + const sessionId = this.sessionId; + if (bindingId && sessionId) { + await this.fetchJson(projectBindingUrl(this.options.attachUrl, bindingId, "session"), { + method: "DELETE", + body: { + sessionId, + terminalReason: { code: "completed", message: "Binding Session completed." }, + }, + }); + } + this.bindingId = undefined; + this.sessionId = undefined; + this.closed = true; }); - if (!response.binding?.bindingId || !response.sessionId) { - throw new CapletsError("SERVER_UNAVAILABLE", "Project Binding session response was invalid."); + this.closeInFlight = close; + try { + await close; + } finally { + if (this.closeInFlight === close && !this.closed) this.closeInFlight = undefined; } - this.bindingId = response.binding.bindingId; - this.sessionId = response.sessionId; - this.startHeartbeat(); + } + + dispose(): void { + this.preparingClose = true; + this.closing = true; + this.closed = true; + this.bindingId = undefined; + this.sessionId = undefined; + this.stopHeartbeat(); + } + + private enqueue(mutation: () => Promise): Promise { + const next = this.mutationChain.then(mutation, mutation); + this.mutationChain = next.then( + () => undefined, + () => undefined, + ); + return next; } private startHeartbeat(): void { + if (this.preparingClose || this.closed || this.closing) return; this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => { - void this.heartbeat().catch((error) => { - this.disconnect(); - this.options.writeErr?.( - `Remote Project Binding heartbeat failed: ${errorMessage(error)}\n`, - ); - }); + if (this.timerHeartbeatQueued) return; + this.timerHeartbeatQueued = true; + const mutationSequence = ++this.mutationSequence; + void this.enqueue(async () => { + if ( + this.preparingClose || + this.closed || + (this.closing && mutationSequence > this.closeMutationBoundary) + ) { + return; + } + await this.heartbeat(); + }) + .catch((error) => { + this.disconnect(); + this.options.writeErr?.( + `Remote Project Binding heartbeat failed: ${errorMessage(error)}\n`, + ); + }) + .finally(() => { + this.timerHeartbeatQueued = false; + }); }, this.options.heartbeatIntervalMs); this.heartbeatTimer.unref?.(); } @@ -1614,9 +1823,10 @@ class RemoteProjectBindingSessionManager implements NativeProjectBindingManager private disconnect(): void { this.stopHeartbeat(); + if (this.preparingClose || this.closing) return; this.bindingId = undefined; this.sessionId = undefined; - this.startPromise = undefined; + this.startInFlight = undefined; } private async heartbeat(): Promise { @@ -1635,6 +1845,16 @@ class RemoteProjectBindingSessionManager implements NativeProjectBindingManager private async fetchJson( url: URL, input: { method: "POST" | "DELETE"; body: unknown }, + ): Promise { + return await withProjectBindingMutationDeadline((signal) => + this.fetchJsonWithinDeadline(url, input, signal), + ); + } + + private async fetchJsonWithinDeadline( + url: URL, + input: { method: "POST" | "DELETE"; body: unknown }, + signal: AbortSignal, ): Promise { const headers = new Headers(this.options.requestInit.headers); headers.set("content-type", "application/json"); @@ -1643,7 +1863,11 @@ class RemoteProjectBindingSessionManager implements NativeProjectBindingManager method: input.method, headers, body: JSON.stringify(input.body), + signal, }); + if (input.method === "DELETE" && response.status === 404) { + return {} as T; + } if (!response.ok) { let payload: unknown; try { diff --git a/packages/core/src/project-binding/workspaces.ts b/packages/core/src/project-binding/workspaces.ts index 2b562845..d6775dd0 100644 --- a/packages/core/src/project-binding/workspaces.ts +++ b/packages/core/src/project-binding/workspaces.ts @@ -3,6 +3,7 @@ import { mkdirSync, readdirSync, readFileSync, + renameSync, rmSync, statSync, writeFileSync, @@ -183,7 +184,10 @@ export class ProjectBindingWorkspaceStore { for (const paths of this.workspacePaths()) { const leases = this.leasesFor(paths); for (const entry of leases) { - if (!entry.lease.active && this.isStaleLease(entry.lease)) { + if ( + this.isStaleLease(entry.lease) && + (!entry.lease.active || entry.lease.expiresAt !== undefined) + ) { rmSync(entry.path, { force: true }); expiredLeases.push(entry.path); } @@ -230,7 +234,12 @@ export class ProjectBindingWorkspaceStore { private readMetadata(projectFingerprint: string): ProjectBindingWorkspaceMetadata | undefined { const path = this.paths(projectFingerprint).metadata; if (!existsSync(path)) return undefined; - return JSON.parse(readFileSync(path, "utf8")) as ProjectBindingWorkspaceMetadata; + try { + const value: unknown = JSON.parse(readFileSync(path, "utf8")); + return isWorkspaceMetadata(value) ? value : undefined; + } catch { + return undefined; + } } private workspacePaths(): ProjectBindingWorkspacePaths[] { @@ -244,12 +253,18 @@ export class ProjectBindingWorkspaceStore { paths: ProjectBindingWorkspacePaths, ): { path: string; lease: ProjectBindingLease }[] { if (!existsSync(paths.leases)) return []; - return readdirSync(paths.leases, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) - .map((entry) => { - const path = join(paths.leases, entry.name); - return { path, lease: JSON.parse(readFileSync(path, "utf8")) as ProjectBindingLease }; - }); + const leases: { path: string; lease: ProjectBindingLease }[] = []; + for (const entry of readdirSync(paths.leases, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const path = join(paths.leases, entry.name); + try { + const value: unknown = JSON.parse(readFileSync(path, "utf8")); + if (isProjectBindingLease(value)) leases.push({ path, lease: value }); + } catch { + // Ignore a torn or corrupt managed lease; valid leases remain reclaimable. + } + } + return leases; } private isStaleLease(lease: ProjectBindingLease): boolean { @@ -272,7 +287,41 @@ type WorkspaceCandidate = { }; function writeJson(path: string, value: unknown): void { - writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + renameSync(temporary, path); + } catch (error) { + rmSync(temporary, { force: true }); + throw error; + } +} + +function isWorkspaceMetadata(value: unknown): value is ProjectBindingWorkspaceMetadata { + return ( + isRecord(value) && + typeof value.projectFingerprint === "string" && + typeof value.projectRoot === "string" && + typeof value.createdAt === "string" && + typeof value.lastActiveAt === "string" + ); +} + +function isProjectBindingLease(value: unknown): value is ProjectBindingLease { + return ( + isRecord(value) && + typeof value.bindingId === "string" && + typeof value.projectFingerprint === "string" && + typeof value.state === "string" && + typeof value.active === "boolean" && + typeof value.updatedAt === "string" && + (value.expiresAt === undefined || typeof value.expiresAt === "string") && + (value.diagnosticCode === undefined || typeof value.diagnosticCode === "string") + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } function directorySize(path: string): number { diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index 4b352cf5..8f8a2ab1 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -133,6 +133,20 @@ type ProjectBindingHttpRecord = { updatedAt: string; expiresAt: string; active: boolean; + generation: number; + mutationTail: Promise; + terminal: boolean; + ownerlessCleanup: Promise | undefined; + heartbeatInFlight: Promise | undefined; + pendingHeartbeat: + | { + message: Extract; + ownerKey: string; + promise: Promise; + resolve: (value: boolean) => void; + reject: (reason?: unknown) => void; + } + | undefined; }; type AttachEventSource = { @@ -155,6 +169,8 @@ export function createHttpServeApp( const defaultAttachSessions = new Map(); const defaultAttachSessionPromises = new Map>(); const projectBindingSessions = new Map(); + let projectBindingSessionsClosing = false; + let projectBindingWorkspaceCleanup: Promise | undefined; const attachEventStreams = new Set(); const attachSessionPruneTimer = setInterval(() => { pruneIdleAttachSessions(); @@ -197,6 +213,7 @@ export function createHttpServeApp( }; const projectBindingWorkspaceStore = io.projectBindingWorkspaceStore ?? new ProjectBindingWorkspaceStore(); + pruneProjectBindingWorkspaces(); if ( options.auth.type === "remote_credentials" && options.trustProxy === true && @@ -378,12 +395,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "summary", baseUrl, dashboardUrl, dashboardPath: paths.dashboard }, ); - if (outcome.kind !== "summary") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host summary returned an invalid outcome.", - ); - } + return c.json(outcome.summary); } catch (error) { return currentHostErrorResponse(error); @@ -398,12 +410,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "caplets_list" }, ); - if (outcome.kind !== "caplets_list") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host Caplets returned an invalid outcome.", - ); - } + return c.json({ caplets: outcome.caplets }); } catch (error) { return currentHostErrorResponse(error); @@ -424,12 +431,7 @@ export function createHttpServeApp( limit: numberQueryParam(query.get("limit")), }, ); - if (outcome.kind !== "catalog_search") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host catalog search returned an invalid outcome.", - ); - } + return c.json({ entries: outcome.entries }); } catch (error) { return currentHostErrorResponse(error); @@ -449,12 +451,7 @@ export function createHttpServeApp( capletId: requiredQueryParam(query, "id"), }, ); - if (outcome.kind !== "catalog_detail") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host catalog detail returned an invalid outcome.", - ); - } + return c.json({ entry: outcome.entry, setupActions: outcome.setupActions, @@ -473,12 +470,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "catalog_updates" }, ); - if (outcome.kind !== "catalog_updates") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host catalog updates returned an invalid outcome.", - ); - } + return c.json({ updates: outcome.updates }); } catch (error) { return currentHostErrorResponse(error); @@ -503,12 +495,7 @@ export function createHttpServeApp( disableCatalogIndexing: true, }, ); - if (outcome.kind !== "catalog_install") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host catalog install returned an invalid outcome.", - ); - } + return c.json({ installed: outcome.installed, setupActions: outcome.setupActions }); } catch (error) { return currentHostErrorResponse(error); @@ -533,12 +520,7 @@ export function createHttpServeApp( disableCatalogIndexing: true, }, ); - if (outcome.kind !== "catalog_update") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host catalog update returned an invalid outcome.", - ); - } + return c.json({ installed: outcome.installed, setupActions: outcome.setupActions }); } catch (error) { return currentHostErrorResponse(error); @@ -553,12 +535,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "clients_list" }, ); - if (outcome.kind !== "clients_list") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host clients returned an invalid outcome.", - ); - } + return c.json({ clients: outcome.clients }); } catch (error) { return currentHostErrorResponse(error); @@ -573,12 +550,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "pending_logins_list" }, ); - if (outcome.kind !== "pending_logins_list") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host pending logins returned an invalid outcome.", - ); - } + return c.json({ pendingLogins: outcome.pendingLogins }); } catch (error) { return currentHostErrorResponse(error); @@ -604,12 +576,7 @@ export function createHttpServeApp( grantedRole: optionalRemoteClientRole(parsed, "grantedRole"), }, ); - if (outcome.kind !== "pending_login_approve") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host approval returned an invalid outcome.", - ); - } + if ("status" in outcome) return c.json({ error: "dashboard_auth_unavailable" }, 404); return c.json({ pendingLogin: outcome.pendingLogin }); } catch (error) { @@ -628,12 +595,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "pending_login_deny", flowId: requiredRouteParam(c.req.param("flowId")) }, ); - if (outcome.kind !== "pending_login_deny") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host denial returned an invalid outcome.", - ); - } + if ("status" in outcome) return c.json({ error: "dashboard_auth_unavailable" }, 404); return c.json({ pendingLogin: outcome.pendingLogin }); } catch (error) { @@ -652,12 +614,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "client_revoke", clientId: requiredRouteParam(c.req.param("clientId")) }, ); - if (outcome.kind !== "client_revoke") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host revocation returned an invalid outcome.", - ); - } + if ("status" in outcome) return c.json({ error: "dashboard_auth_unavailable" }, 404); if (outcome.sessionEnded) { dashboardSessionStore.delete(c.req.header("cookie")); @@ -689,12 +646,7 @@ export function createHttpServeApp( role: requiredRemoteClientRole(parsed, "role"), }, ); - if (outcome.kind !== "client_change_role") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host role change returned an invalid outcome.", - ); - } + if (outcome.status === "credential_store_unavailable") { return c.json({ error: "dashboard_auth_unavailable" }, 404); } @@ -723,12 +675,7 @@ export function createHttpServeApp( action: optionalActivityAction(query.get("action") ?? undefined), }, ); - if (outcome.kind !== "activity_list") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host activity returned an invalid outcome.", - ); - } + return c.json(outcome.activity); } catch (error) { return currentHostErrorResponse(error); @@ -743,12 +690,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "vault_list" }, ); - if (outcome.kind !== "vault_list") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host Vault list returned an invalid outcome.", - ); - } + return c.json({ values: outcome.values, grants: outcome.grants }); } catch (error) { return currentHostErrorResponse(error); @@ -772,12 +714,7 @@ export function createHttpServeApp( force: optionalBooleanField(parsed, "force"), }, ); - if (outcome.kind !== "vault_set") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host Vault set returned an invalid outcome.", - ); - } + return c.json({ status: outcome.status }); } catch (error) { return currentHostErrorResponse(error); @@ -795,12 +732,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "vault_delete", name: requiredRouteParam(c.req.param("key")) }, ); - if (outcome.kind !== "vault_delete") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host Vault delete returned an invalid outcome.", - ); - } + return c.json({ deleted: outcome.deleted }); } catch (error) { return currentHostErrorResponse(error); @@ -824,12 +756,7 @@ export function createHttpServeApp( capletId: stringField(parsed, "capletId"), }, ); - if (outcome.kind !== "vault_access_grant") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host Vault grant returned an invalid outcome.", - ); - } + return c.json({ grant: outcome.grant }); } catch (error) { return currentHostErrorResponse(error); @@ -853,12 +780,7 @@ export function createHttpServeApp( capletId: optionalStringField(parsed, "capletId"), }, ); - if (outcome.kind !== "vault_access_revoke") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host Vault revoke returned an invalid outcome.", - ); - } + return c.json({ revoked: outcome.revoked }); } catch (error) { return currentHostErrorResponse(error); @@ -905,12 +827,7 @@ export function createHttpServeApp( publicOrigin: options.publicOrigin ?? null, }, ); - if (outcome.kind !== "runtime") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host runtime returned an invalid outcome.", - ); - } + return c.json({ runtime: outcome.runtime, daemon: outcome.daemon }); } catch (error) { return currentHostErrorResponse(error); @@ -928,12 +845,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "runtime_restart" }, ); - if (outcome.kind !== "runtime_restart") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host restart returned an invalid outcome.", - ); - } + return c.json({ restartAvailable: outcome.restartAvailable, reason: outcome.reason }, 409); } catch (error) { return currentHostErrorResponse(error); @@ -948,9 +860,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "logs", limit: numberQueryParam(new URL(c.req.url).searchParams.get("limit")) }, ); - if (outcome.kind !== "logs") { - throw new CapletsError("INTERNAL_ERROR", "Current Host logs returned an invalid outcome."); - } + return c.json({ entries: outcome.entries, limit: outcome.limit, @@ -969,12 +879,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "diagnostics" }, ); - if (outcome.kind !== "diagnostics") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host diagnostics returned an invalid outcome.", - ); - } + return c.json({ status: outcome.status, diagnostics: outcome.diagnostics, @@ -993,9 +898,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "runtime_event" }, ); - if (outcome.kind !== "runtime_event") { - throw new CapletsError("INTERNAL_ERROR", "Current Host event returned an invalid outcome."); - } + return new Response( `id: ${Date.now()}\nevent: runtime_health\ndata: ${JSON.stringify(outcome.event)}\n\n`, { @@ -1019,12 +922,7 @@ export function createHttpServeApp( dashboardPrincipalForSession(c, session.session), { kind: "project_binding" }, ); - if (outcome.kind !== "project_binding") { - throw new CapletsError( - "INTERNAL_ERROR", - "Current Host Project Binding returned an invalid outcome.", - ); - } + return c.json({ projectBinding: outcome.projectBinding }); } catch (error) { return currentHostErrorResponse(error); @@ -1387,11 +1285,33 @@ export function createHttpServeApp( } } + function pruneProjectBindingWorkspaces(): void { + if (projectBindingWorkspaceCleanup) return; + const cleanup = projectBindingWorkspaceStore.cleanup(); + projectBindingWorkspaceCleanup = cleanup; + void cleanup.then( + () => { + if (projectBindingWorkspaceCleanup === cleanup) { + projectBindingWorkspaceCleanup = undefined; + } + }, + (error) => { + if (projectBindingWorkspaceCleanup === cleanup) { + projectBindingWorkspaceCleanup = undefined; + } + writeErr(`Could not prune Project Binding workspaces: ${errorMessage(error)}\n`); + }, + ); + } + function pruneExpiredProjectBindingSessions(): void { + pruneProjectBindingWorkspaces(); const now = Date.now(); - for (const [bindingId, record] of projectBindingSessions) { - if (record.active && Date.parse(record.expiresAt) > now) continue; - projectBindingSessions.delete(bindingId); + for (const record of projectBindingSessions.values()) { + if (!record.terminal && record.active && Date.parse(record.expiresAt) > now) continue; + void endProjectBindingRecord(record).catch((error) => { + writeErr(`Could not prune Project Binding session: ${errorMessage(error)}\n`); + }); } } @@ -1462,10 +1382,23 @@ export function createHttpServeApp( return; } if (message.type === "heartbeat") { - await updateProjectBindingHeartbeat(validation.record, message); + const updated = await updateProjectBindingHeartbeat( + validation.record, + message, + validation.durableClientId, + ); + if (!updated) ws.close(1008, "Project Binding session is no longer authorized."); + return; + } + const ended = await endProjectBindingRecord( + validation.record, + validation.durableClientId, + message.reason, + ); + if (!ended) { + ws.close(1008, "Project Binding session is no longer authorized."); return; } - await endProjectBindingRecord(validation.record, message.reason); ws.send(JSON.stringify({ type: "ended", reason: message.reason })); ws.close(1000, message.reason.message); }) @@ -1479,7 +1412,7 @@ export function createHttpServeApp( }, onClose: () => { if (!validation.ok) return; - void endProjectBindingRecord(validation.record, { + void endProjectBindingRecord(validation.record, undefined, { code: "interrupted", message: "Project Binding WebSocket closed.", }).catch((error) => { @@ -1499,6 +1432,9 @@ export function createHttpServeApp( app.post(routePath(paths.projectBindings, "sessions"), accessRouteAuth, async (c) => { try { + if (projectBindingSessionsClosing) { + return c.json({ ok: false, error: { code: "SERVER_UNAVAILABLE" } }, 503); + } const parsed = await parseJsonObject(c.req.json(), "Project Binding session request"); const projectRoot = stringField(parsed, "projectRoot"); const projectFingerprint = @@ -1517,6 +1453,9 @@ export function createHttpServeApp( projectRoot, lastActiveAt: now.toISOString(), }); + if (projectBindingSessionsClosing) { + return c.json({ ok: false, error: { code: "SERVER_UNAVAILABLE" } }, 503); + } const record: ProjectBindingHttpRecord = { ownerKey, bindingId, @@ -1530,8 +1469,20 @@ export function createHttpServeApp( updatedAt: now.toISOString(), expiresAt, active: true, + generation: 0, + mutationTail: Promise.resolve(), + terminal: false, + ownerlessCleanup: undefined, + heartbeatInFlight: undefined, + pendingHeartbeat: undefined, }; await projectBindingWorkspaceStore.writeLease(projectBindingLease(record)); + if (projectBindingSessionsClosing) { + await projectBindingWorkspaceStore.writeLease( + projectBindingLease(terminalProjectBindingCandidate(record)), + ); + return c.json({ ok: false, error: { code: "SERVER_UNAVAILABLE" } }, 503); + } projectBindingSessions.set(bindingId, record); return c.json({ binding: projectBindingResponse(record), sessionId }, 201); } catch (error) { @@ -1569,13 +1520,18 @@ export function createHttpServeApp( if (sessionId !== record.sessionId) { return c.json({ ok: false, error: { code: "REQUEST_INVALID" } }, 403); } - await updateProjectBindingHeartbeat(record, { - type: "heartbeat", - bindingId, - sessionId, - state: projectBindingStateField(parsed, "state", record.state), - syncState: projectBindingSyncStateField(parsed, "syncState", record.syncState), - }); + const updated = await updateProjectBindingHeartbeat( + record, + { + type: "heartbeat", + bindingId, + sessionId, + state: projectBindingStateField(parsed, "state", record.state), + syncState: projectBindingSyncStateField(parsed, "syncState", record.syncState), + }, + ownerKey, + ); + if (!updated) return c.json({ ok: false, error: { code: "AUTH_FAILED" } }, 403); return c.json({ ok: true, binding: projectBindingResponse(record) }); } catch (error) { const safe = toSafeError( @@ -1587,21 +1543,34 @@ export function createHttpServeApp( }); app.delete(routePath(paths.projectBindings, ":bindingId/session"), accessRouteAuth, async (c) => { - const bindingId = requiredRouteParam(c.req.param("bindingId")); - const record = projectBindingRecordFor(bindingId, projectBindingOwnerKey(c)); - if (!record) return c.json({ ok: false, error: { code: "REQUEST_INVALID" } }, 404); - await endProjectBindingRecord(record); - return c.json({ ok: true, binding: projectBindingResponse(record) }); + try { + const bindingId = requiredRouteParam(c.req.param("bindingId")); + const ownerKey = projectBindingOwnerKey(c); + const record = projectBindingRecordFor(bindingId, ownerKey); + if (!record) return c.json({ ok: false, error: { code: "REQUEST_INVALID" } }, 404); + const ended = await endProjectBindingRecord(record, ownerKey); + if (!ended) return c.json({ ok: false, error: { code: "AUTH_FAILED" } }, 403); + return c.json({ ok: true, binding: projectBindingResponse(record) }); + } catch (error) { + const safe = toSafeError( + error, + error instanceof CapletsError ? error.code : "REQUEST_INVALID", + ); + return c.json({ ok: false, error: safe }, error instanceof CapletsError ? 400 : 500); + } }); function projectBindingSocketRecordForRequest( c: Parameters[0], - ): { ok: true; record: ProjectBindingHttpRecord } | { ok: false; response: Response } { + ): + | { ok: true; record: ProjectBindingHttpRecord; durableClientId: string } + | { ok: false; response: Response } { const url = new URL(c.req.url); const bindingId = url.searchParams.get("bindingId") ?? ""; const sessionId = url.searchParams.get("sessionId") ?? ""; const projectFingerprint = url.searchParams.get("projectFingerprint") ?? ""; - const record = projectBindingRecordFor(bindingId, projectBindingOwnerKey(c)); + const durableClientId = projectBindingOwnerKey(c); + const record = projectBindingRecordFor(bindingId, durableClientId); if (!record) { return { ok: false, @@ -1629,21 +1598,170 @@ export function createHttpServeApp( }), }; } - return { ok: true, record }; + return { ok: true, record, durableClientId }; } async function updateProjectBindingHeartbeat( record: ProjectBindingHttpRecord, message: Extract, + ownerKey: string, + ): Promise { + if (record.heartbeatInFlight) { + if (record.pendingHeartbeat) { + record.pendingHeartbeat.message = message; + record.pendingHeartbeat.ownerKey = ownerKey; + return await record.pendingHeartbeat.promise; + } + const pending = Promise.withResolvers(); + record.pendingHeartbeat = { + message, + ownerKey, + promise: pending.promise, + resolve: pending.resolve, + reject: pending.reject, + }; + return await pending.promise; + } + + const heartbeat = enqueueProjectBindingMutation(record, async () => { + if (!canMutateProjectBindingRecord(record, ownerKey)) { + await terminalizeProjectBindingRecordInQueue(record); + return false; + } + + const generation = record.generation; + const expiresAt = record.expiresAt; + const updatedAt = new Date().toISOString(); + const candidate: ProjectBindingHttpRecord = { + ...record, + state: message.state, + syncState: message.syncState, + updatedAt, + expiresAt: new Date(Date.parse(updatedAt) + PROJECT_BINDING_LEASE_TTL_MS).toISOString(), + generation: generation + 1, + }; + await projectBindingWorkspaceStore.writeLease(projectBindingLease(candidate)); + + if (!canCommitProjectBindingHeartbeat(record, ownerKey, generation, expiresAt)) { + await terminalizeProjectBindingRecordInQueue(record, candidate); + return false; + } + + record.state = candidate.state; + record.syncState = candidate.syncState; + record.updatedAt = candidate.updatedAt; + record.expiresAt = candidate.expiresAt; + record.generation = candidate.generation; + return true; + }); + record.heartbeatInFlight = heartbeat; + void heartbeat.then( + () => continuePendingProjectBindingHeartbeat(record, heartbeat), + () => continuePendingProjectBindingHeartbeat(record, heartbeat), + ); + return await heartbeat; + } + + function continuePendingProjectBindingHeartbeat( + record: ProjectBindingHttpRecord, + completed: Promise, + ): void { + if (record.heartbeatInFlight !== completed) return; + record.heartbeatInFlight = undefined; + const pending = record.pendingHeartbeat; + record.pendingHeartbeat = undefined; + if (!pending) return; + void updateProjectBindingHeartbeat(record, pending.message, pending.ownerKey).then( + pending.resolve, + pending.reject, + ); + } + + function enqueueProjectBindingMutation( + record: ProjectBindingHttpRecord, + mutation: () => Promise, + ): Promise { + const queued = record.mutationTail.then(mutation, mutation); + record.mutationTail = queued.then( + () => undefined, + () => undefined, + ); + return queued; + } + + function canMutateProjectBindingRecord( + record: ProjectBindingHttpRecord, + ownerKey: string, + ): boolean { + return ( + !projectBindingSessionsClosing && + projectBindingSessions.get(record.bindingId) === record && + record.active && + !record.terminal && + Date.parse(record.expiresAt) > Date.now() && + isCurrentProjectBindingAccessOwner(record, ownerKey) + ); + } + + function canCommitProjectBindingHeartbeat( + record: ProjectBindingHttpRecord, + ownerKey: string, + generation: number, + expiresAt: string, + ): boolean { + return ( + canMutateProjectBindingRecord(record, ownerKey) && + record.generation === generation && + record.expiresAt === expiresAt + ); + } + + function isCurrentProjectBindingAccessOwner( + record: ProjectBindingHttpRecord, + ownerKey: string, + ): boolean { + if (record.ownerKey !== ownerKey) return false; + if (!remoteCredentialStore) return ownerKey === "development_unauthenticated"; + const client = remoteCredentialStore + .listClients() + .find((candidate) => candidate.clientId === ownerKey); + return client?.role === "access" && client.revokedAt === undefined; + } + + async function terminalizeProjectBindingRecordInQueue( + record: ProjectBindingHttpRecord, + candidate?: ProjectBindingHttpRecord, + removeCurrent = true, ): Promise { - record.state = message.state; - record.syncState = message.syncState; + const current = projectBindingSessions.get(record.bindingId) === record; + const target = current ? record : candidate; + if (!target) return; + if (current && !record.terminal) terminalizeProjectBindingRecord(record); + const terminal = current ? record : terminalProjectBindingCandidate(target); + + await projectBindingWorkspaceStore.writeLease(projectBindingLease(terminal)); + if (current) { + if (removeCurrent && projectBindingSessions.get(record.bindingId) === record) { + projectBindingSessions.delete(record.bindingId); + } + } + } + + function terminalizeProjectBindingRecord(record: ProjectBindingHttpRecord): void { + record.state = "ended"; + record.syncState = "not_started"; + record.active = false; record.updatedAt = new Date().toISOString(); - record.expiresAt = new Date( - Date.parse(record.updatedAt) + PROJECT_BINDING_LEASE_TTL_MS, - ).toISOString(); - record.active = true; - await projectBindingWorkspaceStore.writeLease(projectBindingLease(record)); + record.generation += 1; + record.terminal = true; + } + + function terminalProjectBindingCandidate( + record: ProjectBindingHttpRecord, + ): ProjectBindingHttpRecord { + const terminal = { ...record }; + terminalizeProjectBindingRecord(terminal); + return terminal; } function sendProjectBindingReadyWhenOpen( @@ -1669,15 +1787,51 @@ export function createHttpServeApp( async function endProjectBindingRecord( record: ProjectBindingHttpRecord, + ownerKey?: string | undefined, _reason?: BindingTerminalReason | undefined, - ): Promise { - if (!projectBindingSessions.has(record.bindingId) || !record.active) return; - record.state = "ended"; - record.syncState = "not_started"; - record.active = false; - record.updatedAt = new Date().toISOString(); - await projectBindingWorkspaceStore.writeLease(projectBindingLease(record)); - projectBindingSessions.delete(record.bindingId); + ): Promise { + if (ownerKey === undefined) { + if (record.ownerlessCleanup) return await record.ownerlessCleanup; + if (projectBindingSessions.get(record.bindingId) !== record) return false; + const cleanup = enqueueProjectBindingMutation(record, async () => { + if (projectBindingSessions.get(record.bindingId) !== record) return false; + await terminalizeProjectBindingRecordInQueue(record); + return true; + }); + record.ownerlessCleanup = cleanup; + void cleanup.then( + () => { + if (record.ownerlessCleanup === cleanup) record.ownerlessCleanup = undefined; + }, + () => { + if (record.ownerlessCleanup === cleanup) record.ownerlessCleanup = undefined; + }, + ); + return await cleanup; + } + + return await enqueueProjectBindingMutation(record, async () => { + const generation = record.generation; + const expiresAt = record.expiresAt; + if (!canMutateProjectBindingRecord(record, ownerKey)) { + await terminalizeProjectBindingRecordInQueue(record); + return false; + } + await terminalizeProjectBindingRecordInQueue(record, undefined, false); + const canAcknowledge = + !projectBindingSessionsClosing && + projectBindingSessions.get(record.bindingId) === record && + record.terminal && + !record.active && + record.generation === generation + 1 && + record.expiresAt === expiresAt && + Date.parse(expiresAt) > Date.now() && + isCurrentProjectBindingAccessOwner(record, ownerKey); + if (projectBindingSessions.get(record.bindingId) === record) { + projectBindingSessions.delete(record.bindingId); + } + return canAcknowledge; + }); } function projectBindingOwnerKey(c: Parameters[0]): string { @@ -1695,8 +1849,15 @@ export function createHttpServeApp( ): ProjectBindingHttpRecord | undefined { const record = projectBindingSessions.get(bindingId); if (!record || record.ownerKey !== ownerKey) return undefined; - if (!record.active || Date.parse(record.expiresAt) <= Date.now()) { - projectBindingSessions.delete(bindingId); + if ( + projectBindingSessionsClosing || + record.terminal || + !record.active || + Date.parse(record.expiresAt) <= Date.now() + ) { + void endProjectBindingRecord(record).catch((error) => { + writeErr(`Could not expire Project Binding session: ${errorMessage(error)}\n`); + }); return undefined; } return record; @@ -1910,6 +2071,7 @@ export function createHttpServeApp( app.closeCapletsSessions = async () => { clearInterval(attachSessionPruneTimer); + projectBindingSessionsClosing = true; for (const stream of attachEventStreams) { stream.close(); } @@ -1924,7 +2086,9 @@ export function createHttpServeApp( await Promise.allSettled([...defaultAttachSessions.values()].map((session) => session.close())); defaultAttachSessions.clear(); defaultAttachSessionPromises.clear(); - projectBindingSessions.clear(); + await Promise.all( + [...projectBindingSessions.values()].map((record) => endProjectBindingRecord(record)), + ); }; if (options.warnUnauthenticatedNetwork) { diff --git a/packages/core/test/cloud-presence.test.ts b/packages/core/test/cloud-presence.test.ts index e2f2ae62..8b969d08 100644 --- a/packages/core/test/cloud-presence.test.ts +++ b/packages/core/test/cloud-presence.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { LocalPresenceManager } from "../src/cloud/presence"; +import { ProjectBindingSessionManager } from "../src/cloud/presence"; +import { NativeProjectBindingLifecycle } from "../src/native/project-binding-lifecycle"; -describe("LocalPresenceManager", () => { +describe("ProjectBindingSessionManager", () => { it("registers and stops local presence", async () => { const client = { registerPresence: vi.fn(async () => ({ @@ -10,7 +11,7 @@ describe("LocalPresenceManager", () => { })), stopPresence: vi.fn(async () => undefined), }; - const manager = new LocalPresenceManager({ + const manager = new ProjectBindingSessionManager({ client, workspaceId: "ws_1", projectRoot: "/repo", @@ -22,7 +23,7 @@ describe("LocalPresenceManager", () => { await manager.close(); expect(client.registerPresence).toHaveBeenCalledOnce(); - expect(client.stopPresence).toHaveBeenCalledWith("presence_1"); + expect(client.stopPresence).toHaveBeenCalledWith("presence_1", expect.anything()); }); it("does not stop before registration succeeds", async () => { @@ -32,7 +33,7 @@ describe("LocalPresenceManager", () => { }), stopPresence: vi.fn(async () => undefined), }; - const manager = new LocalPresenceManager({ + const manager = new ProjectBindingSessionManager({ client, workspaceId: "ws_1", projectRoot: "/repo", @@ -46,6 +47,51 @@ describe("LocalPresenceManager", () => { expect(client.stopPresence).not.toHaveBeenCalled(); }); + it("aborts a timed out Cloud registration and ignores its late result", async () => { + vi.useFakeTimers(); + try { + const registrationStarted = Promise.withResolvers(); + const lateRegistration = Promise.withResolvers<{ + presenceId: string; + expiresAt: string; + }>(); + let registrationSignal: AbortSignal | undefined; + const client = { + registerPresence: vi.fn(async (_input: unknown, options?: { signal?: AbortSignal }) => { + registrationSignal = options?.signal; + registrationStarted.resolve(); + return await lateRegistration.promise; + }), + stopPresence: vi.fn(async () => undefined), + }; + const manager = new ProjectBindingSessionManager({ + client, + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + allowedCapletIds: ["repo-cli"], + mutationTimeoutMs: 100, + }); + + const starting = manager.start(); + await registrationStarted.promise; + vi.advanceTimersToNextTimer(); + await expect(starting).rejects.toThrow("timed out"); + expect(registrationSignal?.aborted).toBe(true); + + lateRegistration.resolve({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:05:00.000Z", + }); + await Promise.resolve(); + await manager.close(); + + expect(client.stopPresence).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("heartbeats active local presence until closed", async () => { vi.useFakeTimers(); const client = { @@ -56,7 +102,7 @@ describe("LocalPresenceManager", () => { heartbeatPresence: vi.fn(async () => undefined), stopPresence: vi.fn(async () => undefined), }; - const manager = new LocalPresenceManager({ + const manager = new ProjectBindingSessionManager({ client, workspaceId: "ws_1", projectRoot: "/repo", @@ -71,7 +117,7 @@ describe("LocalPresenceManager", () => { await vi.advanceTimersByTimeAsync(1_000); expect(client.heartbeatPresence).toHaveBeenCalledTimes(1); - expect(client.heartbeatPresence).toHaveBeenCalledWith("presence_1"); + expect(client.heartbeatPresence).toHaveBeenCalledWith("presence_1", expect.anything()); vi.useRealTimers(); }); @@ -83,7 +129,7 @@ describe("LocalPresenceManager", () => { })), updatePresenceCaplets: vi.fn(async () => undefined), }; - const manager = new LocalPresenceManager({ + const manager = new ProjectBindingSessionManager({ client, workspaceId: "ws_1", projectRoot: "/repo", @@ -94,6 +140,236 @@ describe("LocalPresenceManager", () => { await manager.start(); await manager.updateAllowedCapletIds(["repo-cli", "eslint"]); - expect(client.updatePresenceCaplets).toHaveBeenCalledWith("presence_1", ["repo-cli", "eslint"]); + expect(client.updatePresenceCaplets).toHaveBeenCalledWith( + "presence_1", + ["repo-cli", "eslint"], + expect.anything(), + ); + }); + it("serializes heartbeat, allowed-ID update, and stop so cleanup is last", async () => { + vi.useFakeTimers(); + try { + const heartbeatStarted = Promise.withResolvers(); + const releaseHeartbeat = Promise.withResolvers(); + const events: string[] = []; + const client = { + registerPresence: vi.fn(async () => ({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:05:00.000Z", + })), + heartbeatPresence: vi.fn(async () => { + events.push("heartbeat"); + heartbeatStarted.resolve(); + await releaseHeartbeat.promise; + }), + updatePresenceCaplets: vi.fn(async () => { + events.push("update"); + }), + stopPresence: vi.fn(async () => { + events.push("stop"); + }), + }; + const manager = new ProjectBindingSessionManager({ + client, + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + allowedCapletIds: ["repo-cli"], + heartbeatIntervalMs: 1_000, + }); + + await manager.start(); + await vi.advanceTimersByTimeAsync(1_000); + await Promise.resolve(); + await heartbeatStarted.promise; + const updating = manager.updateAllowedCapletIds(["eslint"]); + const closing = manager.close(); + await Promise.resolve(); + await Promise.resolve(); + + expect(client.stopPresence).not.toHaveBeenCalled(); + releaseHeartbeat.resolve(); + await Promise.all([updating, closing]); + + expect(events).toEqual(["heartbeat", "update", "stop"]); + } finally { + vi.useRealTimers(); + } + }); + + it("times out a stalled Cloud heartbeat, reports it, and releases serialized close", async () => { + vi.useFakeTimers(); + try { + const heartbeatStarted = Promise.withResolvers(); + let heartbeatSignal: AbortSignal | undefined; + const onError = vi.fn(); + const client = { + registerPresence: vi.fn(async () => ({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:05:00.000Z", + })), + heartbeatPresence: vi.fn( + async (_presenceId: string, options?: { signal?: AbortSignal }) => { + heartbeatSignal = options?.signal; + heartbeatStarted.resolve(); + return await new Promise(() => undefined); + }, + ), + updatePresenceCaplets: vi.fn(async () => undefined), + stopPresence: vi.fn(async () => undefined), + }; + const manager = new ProjectBindingSessionManager({ + client, + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + allowedCapletIds: ["repo-cli"], + heartbeatIntervalMs: 1_000, + mutationTimeoutMs: 100, + onError, + }); + + await manager.start(); + vi.advanceTimersToNextTimer(); + await Promise.resolve(); + await heartbeatStarted.promise; + const closing = manager.close(); + vi.advanceTimersToNextTimer(); + await Promise.resolve(); + await closing; + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringMatching(/timed out/u) }), + ); + expect(heartbeatSignal?.aborted).toBe(true); + expect(client.stopPresence).toHaveBeenCalledWith("presence_1", expect.anything()); + } finally { + vi.useRealTimers(); + } + }); + + it("coalesces timer heartbeats while preserving explicit update and close ordering", async () => { + vi.useFakeTimers(); + try { + const heartbeatStarted = Promise.withResolvers(); + const releaseHeartbeat = Promise.withResolvers(); + const events: string[] = []; + const client = { + registerPresence: vi.fn(async () => ({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:05:00.000Z", + })), + heartbeatPresence: vi.fn(async () => { + events.push("heartbeat"); + heartbeatStarted.resolve(); + await releaseHeartbeat.promise; + }), + updatePresenceCaplets: vi.fn(async () => { + events.push("update"); + }), + stopPresence: vi.fn(async () => { + events.push("stop"); + }), + }; + const manager = new ProjectBindingSessionManager({ + client, + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + allowedCapletIds: ["repo-cli"], + heartbeatIntervalMs: 1_000, + }); + + await manager.start(); + vi.advanceTimersByTime(3_000); + await heartbeatStarted.promise; + const updating = manager.updateAllowedCapletIds(["eslint"]); + releaseHeartbeat.resolve(); + await updating; + await manager.close(); + + expect(client.heartbeatPresence).toHaveBeenCalledOnce(); + expect(events).toEqual(["heartbeat", "update", "stop"]); + } finally { + vi.useRealTimers(); + } + }); + + it("reports a heartbeat failure without disconnecting the active Cloud presence", async () => { + vi.useFakeTimers(); + try { + const failure = new Error("offline"); + const onError = vi.fn(); + const client = { + registerPresence: vi.fn(async () => ({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:05:00.000Z", + })), + heartbeatPresence: vi.fn(async () => { + throw failure; + }), + updatePresenceCaplets: vi.fn(async () => undefined), + stopPresence: vi.fn(async () => undefined), + }; + const manager = new ProjectBindingSessionManager({ + client, + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + allowedCapletIds: ["repo-cli"], + heartbeatIntervalMs: 1_000, + onError, + }); + + await manager.start(); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(failure)); + await manager.updateAllowedCapletIds(["eslint"]); + await manager.close(); + + expect(client.updatePresenceCaplets).toHaveBeenCalledWith( + "presence_1", + ["eslint"], + expect.anything(), + ); + expect(client.stopPresence).toHaveBeenCalledWith("presence_1", expect.anything()); + } finally { + vi.useRealTimers(); + } + }); + + it("retries a failed Cloud allowed-ID PATCH instead of acknowledging it through an active start", async () => { + let updateAttempts = 0; + const client = { + registerPresence: vi.fn(async () => ({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:05:00.000Z", + })), + updatePresenceCaplets: vi.fn(async () => { + updateAttempts += 1; + if (updateAttempts === 1) throw new Error("offline"); + }), + }; + const lifecycle = new NativeProjectBindingLifecycle( + new ProjectBindingSessionManager({ + client, + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + allowedCapletIds: ["alpha"], + }), + ["alpha"], + ); + + await lifecycle.start(); + await expect(lifecycle.updateAllowedCapletIds(["bravo"])).rejects.toThrow("offline"); + await lifecycle.start(); + + expect(client.updatePresenceCaplets).toHaveBeenCalledTimes(2); + expect(client.updatePresenceCaplets).toHaveBeenLastCalledWith( + "presence_1", + ["bravo"], + expect.anything(), + ); }); }); diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index 7310211b..3c36b3c6 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -3,10 +3,13 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ProjectBindingSessionManager } from "../src/cloud/presence"; +import { + NativeProjectBindingLifecycle, + type ProjectBindingSessionAdapter, +} from "../src/native/project-binding-lifecycle"; import { CAPLETS_ATTACH_SESSION_HEADER, buildNativeAttachProjection } from "../src/attach/api"; import { listCodeModeCallableCaplets } from "../src/code-mode/api"; -import type { CapletsError } from "../src/errors"; +import { CapletsError } from "../src/errors"; import { CloudAuthStore } from "../src/cloud-auth/store"; import { CapletsEngine } from "../src/engine"; import { @@ -2190,6 +2193,86 @@ describe("createNativeCapletsService remote mode", () => { await service.close(); }); + it("recreates a fresh binding adapter after a profile replacement binding start gets 503", async () => { + const authDir = mkdtempSync(join(tmpdir(), "caplets-native-remote-auth-")); + dirs.push(authDir); + const store = new FileRemoteProfileStore({ root: join(authDir, "remote-profiles") }); + await store.saveSelfHostedProfile({ + hostUrl: "http://127.0.0.1:5387", + clientId: "client_123", + clientLabel: "Native Test", + credentials: { + accessToken: "old-access-token", + refreshToken: "old-refresh-token", + expiresAt: "2999-06-19T12:00:00.000Z", + }, + }); + let sessionStarts = 0; + const fetch = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(input.toString()); + if (url.pathname.endsWith("/v1/attach/sessions") && init?.method === "POST") { + return Response.json({ sessionId: "attach_session_1" }, { status: 201 }); + } + if ( + url.pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST" + ) { + sessionStarts += 1; + if (sessionStarts === 2) { + return new Response("temporarily unavailable", { status: 503 }); + } + return Response.json( + { + binding: { bindingId: `binding_${sessionStarts}`, state: "attaching" }, + sessionId: `session_${sessionStarts}`, + }, + { status: 201 }, + ); + } + if (url.pathname.endsWith("/heartbeat") && init?.method === "POST") { + return Response.json({ ok: true }); + } + if (url.pathname.endsWith("/session") && init?.method === "DELETE") { + return Response.json({ ok: true }); + } + if (url.pathname.endsWith("/manifest")) { + return Response.json(attachManifest("rev-1", "export-1")); + } + return Response.json({ ok: true }); + }, + ); + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const service = createNativeCapletsService({ + mode: "remote", + authDir, + remote: { url: "http://127.0.0.1:5387", fetch }, + configPath, + projectConfigPath, + projectRoot: dirname(dirname(projectConfigPath)), + }); + + await service.reload(); + await vi.waitFor(() => expect(sessionStarts).toBe(1)); + await store.saveSelfHostedProfile({ + hostUrl: "http://127.0.0.1:5387", + clientId: "client_123", + clientLabel: "Native Test", + credentials: { + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: "2999-06-19T12:00:00.000Z", + }, + }); + await service.reload(); + await vi.waitFor(() => expect(sessionStarts).toBe(2)); + await service.reload(); + await vi.waitFor(() => expect(sessionStarts).toBe(3)); + + await service.close(); + }); + it("does not start replacement presence when closed during remote replacement", async () => { const fixture = client([{ name: "alpha", title: "Alpha" }]); const previousCloseStarted = deferred(); @@ -2213,8 +2296,8 @@ describe("createNativeCapletsService remote mode", () => { }) as NativeCapletsService & { replaceRemote( remote: NativeCapletsService, - presence?: ProjectBindingSessionManager, - ): Promise; + presence?: ProjectBindingSessionAdapter, + ): Promise; }; const replacement = { listTools: vi.fn(() => []), @@ -2227,7 +2310,8 @@ describe("createNativeCapletsService remote mode", () => { start: vi.fn(async () => undefined), close: vi.fn(async () => undefined), updateAllowedCapletIds: vi.fn(async () => undefined), - } as unknown as ProjectBindingSessionManager; + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; const replacing = service.replaceRemote(replacement, replacementPresence); await previousCloseStarted.promise; @@ -2235,7 +2319,7 @@ describe("createNativeCapletsService remote mode", () => { releasePreviousClose.resolve(); await Promise.all([replacing, closing]); - expect(replacementPresence.close).toHaveBeenCalledTimes(1); + expect(replacementPresence.dispose).toHaveBeenCalledTimes(1); expect(replacementPresence.start).not.toHaveBeenCalled(); }); @@ -4008,11 +4092,16 @@ describe("createNativeCapletsService remote mode", () => { .map(([, init]) => init?.body) .filter((body): body is string => typeof body === "string") .map( - (body) => JSON.parse(body) as { projectFiles?: Array<{ path: string; content: string }> }, + (body) => + JSON.parse(body) as { + projectFiles?: Array<{ path: string; content: string }>; + allowedCapletIds?: string[]; + }, ); expect(projectBindingBodies[0]?.projectFiles).toEqual([ { path: ".caplets/config.json", content: "{}" }, ]); + expect(projectBindingBodies[0]?.allowedCapletIds).toEqual(["code_mode", "local"]); expect(fetch).toHaveBeenCalledWith( new URL("https://cloud.caplets.dev/api/project-bindings/presence_1"), expect.objectContaining({ @@ -4039,6 +4128,9 @@ describe("createNativeCapletsService remote mode", () => { { status: 201 }, ); } + if (url.pathname.endsWith("/heartbeat") && init?.method === "POST") { + return Response.json({ ok: true }); + } if ( url.pathname.endsWith("/v1/attach/project-bindings/binding_1/session") && init?.method === "DELETE" @@ -4089,7 +4181,7 @@ describe("createNativeCapletsService remote mode", () => { ); expect(bodies[0]).toMatchObject({ projectRoot, - allowedCapletIds: [], + allowedCapletIds: ["code_mode", "local"], }); expect(fetch).toHaveBeenCalledWith( new URL("http://127.0.0.1:5387/v1/attach/project-bindings/binding_1/session"), @@ -4436,10 +4528,1006 @@ describe("createNativeCapletsService remote mode", () => { await service.close(); }); - it("fails fast for invalid remote config", () => { - expect(() => - createNativeCapletsService({ mode: "remote", remote: { url: "http://example.com" } }), - ).toThrow(/https/u); + it("updates binding IDs from resolved local watches and retains the last accepted IDs on failed reload", async () => { + const fixture = client(); + const localListeners = new Set<(tools: NativeCapletTool[]) => void>(); + const localTool = (caplet: string): NativeCapletTool => ({ + caplet, + toolName: caplet, + title: caplet, + description: caplet, + promptGuidance: [], + }); + let localTools: NativeCapletTool[] = []; + let localReloadSucceeds = true; + const localService: NativeCapletsService = { + listTools: () => localTools, + execute: vi.fn(async () => undefined), + reload: vi.fn(async () => localReloadSucceeds), + onToolsChanged: (listener) => { + localListeners.add(listener); + return () => localListeners.delete(listener); + }, + close: vi.fn(async () => undefined), + }; + const registrations: string[][] = []; + const updates: string[][] = []; + const fetch = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(input.toString()); + const body = JSON.parse(String(init?.body ?? "{}")) as { allowedCapletIds?: string[] }; + if (url.pathname.endsWith("/api/project-bindings") && init?.method === "POST") { + registrations.push(body.allowedCapletIds ?? []); + return Response.json({ binding: { bindingId: "presence_1" } }); + } + if (url.pathname.endsWith("/api/project-bindings/presence_1") && init?.method === "PATCH") { + if (body.allowedCapletIds) updates.push(body.allowedCapletIds); + return Response.json({ binding: { bindingId: "presence_1" } }); + } + return new Response("not found", { status: 404 }); + }, + ); + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const service = createNativeCapletsService({ + mode: "remote", + remote: { + url: "http://127.0.0.1:5387", + fetch, + cloud: { + url: "https://cloud.caplets.dev", + accessToken: "token", + workspaceId: "ws_1", + projectRoot: dirname(dirname(projectConfigPath)), + }, + }, + remoteClientFactory: vi.fn(() => fixture.api), + localServiceFactory: vi.fn(() => localService), + configPath, + projectConfigPath, + }); + await Promise.resolve(); + await Promise.resolve(); + localTools = [localTool("alpha")]; + for (const listener of localListeners) listener(localTools); + + await vi.waitFor(() => expect(registrations).toEqual([["alpha"]])); + localTools = [localTool("bravo")]; + for (const listener of localListeners) listener(localTools); + await vi.waitFor(() => expect(updates).toEqual([["bravo"]])); + for (const listener of localListeners) listener(localTools); + await Promise.resolve(); + + expect(updates).toEqual([["bravo"]]); + + localTools = [localTool("charlie")]; + localReloadSucceeds = false; + await service.reload(); + expect(updates).toEqual([["bravo"]]); + + localReloadSucceeds = true; + await service.reload(); + await vi.waitFor(() => expect(updates).toEqual([["bravo"], ["charlie"]])); + await service.close(); + }); + + it("restores the current remote subscription and closes an uncommitted remote after old cleanup fails", async () => { + const fixture = client(); + const localService: NativeCapletsService = { + listTools: () => [], + execute: vi.fn(async () => undefined), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => undefined), + close: vi.fn(async () => undefined), + }; + const service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => fixture.api), + localServiceFactory: vi.fn(() => localService), + }); + const oldListeners = new Set<(tools: NativeCapletTool[]) => void>(); + const oldClose = vi.fn(async (): Promise => { + throw new Error("old remote close failed"); + }); + const oldRemote: NativeCapletsService = { + listTools: () => [], + execute: vi.fn(async () => undefined), + reload: vi.fn(async () => true), + onToolsChanged: (listener) => { + oldListeners.add(listener); + return () => oldListeners.delete(listener); + }, + close: oldClose, + }; + const candidateClose = vi.fn(async () => undefined); + const candidate: NativeCapletsService = { + listTools: () => [], + execute: vi.fn(async () => undefined), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => undefined), + close: candidateClose, + }; + type CompositeRemoteProbe = NativeCapletsService & { + remote: NativeCapletsService; + replaceRemote(remote: NativeCapletsService, remoteIdentity?: string): Promise; + }; + const composite = service as unknown as CompositeRemoteProbe; + composite.remote = oldRemote; + + await expect(composite.replaceRemote(candidate, "candidate")).rejects.toThrow( + "old remote close failed", + ); + + expect(composite.remote).toBe(oldRemote); + expect(oldListeners.size).toBe(1); + expect(candidateClose).toHaveBeenCalledOnce(); + + oldClose.mockResolvedValue(undefined); + await service.close(); + }); + + it("silently latches the exact legacy unsupported binding error during replacement", async () => { + const fixture = client(); + const fetch = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(input.toString()); + if ( + url.pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST" + ) { + return Response.json( + { + binding: { bindingId: "binding_1", state: "attaching" }, + sessionId: "session_1", + }, + { status: 201 }, + ); + } + if (url.pathname.endsWith("/heartbeat") && init?.method === "POST") { + return Response.json({ ok: true }); + } + if ( + url.pathname.endsWith("/v1/attach/project-bindings/binding_1/session") && + init?.method === "DELETE" + ) { + return Response.json({ ok: true }); + } + return new Response("not found", { status: 404 }); + }, + ); + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const writeErr = vi.fn(); + const service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387", fetch }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + projectRoot: dirname(dirname(projectConfigPath)), + writeErr, + }); + const replacementClose = vi.fn(async () => undefined); + const replacement: NativeCapletsService = { + listTools: () => [], + execute: vi.fn(async () => undefined), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => undefined), + close: replacementClose, + }; + const unsupportedCandidate = { + start: vi.fn(async () => { + throw new CapletsError( + "UNSUPPORTED_CAPABILITY", + "Self-hosted Project Binding sessions are not implemented by this runtime.", + ); + }), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + type CompositeReplacementProbe = NativeCapletsService & { + replaceRemote( + remote: NativeCapletsService, + remoteIdentity: string, + presence: ProjectBindingSessionAdapter, + ): Promise; + }; + + await vi.waitFor(() => + expect( + fetch.mock.calls.some( + ([input, init]) => + new URL(input.toString()).pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST", + ), + ).toBe(true), + ); + const composite = service as unknown as CompositeReplacementProbe; + await composite.replaceRemote(replacement, "replacement", unsupportedCandidate); + + expect(unsupportedCandidate.dispose).toHaveBeenCalledOnce(); + expect(writeErr).not.toHaveBeenCalled(); + await service.close(); + expect(replacementClose).toHaveBeenCalledOnce(); + }); + + it("disconnects and re-registers self-hosted bindings after an allowed-ID heartbeat rejection", async () => { + const fixture = client(); + const sessionBodies: string[] = []; + let sessionCount = 0; + const writeErr = vi.fn(); + const fetch = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(input.toString()); + if ( + url.pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST" + ) { + sessionCount += 1; + sessionBodies.push(String(init.body)); + return Response.json( + { + binding: { bindingId: `binding_${sessionCount}`, state: "attaching" }, + sessionId: `session_${sessionCount}`, + }, + { status: 201 }, + ); + } + if (url.pathname.endsWith("/heartbeat") && init?.method === "POST") { + return new Response("expired", { status: 503 }); + } + return Response.json({ ok: true }); + }, + ); + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387", fetch }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + projectRoot: dirname(dirname(projectConfigPath)), + writeErr, + }); + type BindingLifecycleProbe = { + start(): Promise; + updateAllowedCapletIds(allowedCapletIds: string[]): Promise; + }; + type CompositeBindingProbe = { + projectBinding: BindingLifecycleProbe | undefined; + }; + + await vi.waitFor(() => expect(sessionCount).toBe(1)); + const projectBinding = (service as unknown as CompositeBindingProbe).projectBinding; + if (!projectBinding) throw new Error("Project Binding lifecycle was not created."); + await expect(projectBinding.updateAllowedCapletIds(["bravo"])).rejects.toThrow( + "Project Binding request failed (503).", + ); + await vi.waitFor(() => + expect(writeErr).toHaveBeenCalledWith( + "Remote Project Binding heartbeat failed: Project Binding request failed (503).\n", + ), + ); + await projectBinding.start(); + await vi.waitFor(() => expect(sessionCount).toBe(2)); + + expect(sessionBodies[1]).toContain('"bravo"'); + await service.close(); + }); + + it("keeps cleanup IDs through an in-flight self-hosted heartbeat failure during close", async () => { + vi.useFakeTimers(); + try { + const fixture = client(); + const heartbeatStarted = Promise.withResolvers(); + const releaseHeartbeat = Promise.withResolvers(); + let deleteCalls = 0; + const fetch = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(input.toString()); + if ( + url.pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST" + ) { + return Response.json( + { + binding: { bindingId: "binding_1", state: "attaching" }, + sessionId: "session_1", + }, + { status: 201 }, + ); + } + if (url.pathname.endsWith("/heartbeat") && init?.method === "POST") { + heartbeatStarted.resolve(); + await releaseHeartbeat.promise; + return new Response("expired", { status: 503 }); + } + if ( + url.pathname.endsWith("/v1/attach/project-bindings/binding_1/session") && + init?.method === "DELETE" + ) { + deleteCalls += 1; + return Response.json({ ok: true }); + } + return new Response("not found", { status: 404 }); + }, + ); + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387", fetch }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + projectRoot: dirname(dirname(projectConfigPath)), + }); + + await vi.waitFor(() => + expect( + fetch.mock.calls.some( + ([input, init]) => + new URL(input.toString()).pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST", + ), + ).toBe(true), + ); + const advancing = vi.advanceTimersByTimeAsync(30_000); + await heartbeatStarted.promise; + const closing = service.close(); + releaseHeartbeat.resolve(); + await Promise.all([advancing, closing]); + + expect(deleteCalls).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it("times out a stalled self-hosted heartbeat and releases re-registration and close", async () => { + vi.useFakeTimers(); + try { + const fixture = client(); + const heartbeatStarted = Promise.withResolvers(); + let heartbeatSignal: AbortSignal | undefined; + let sessionCount = 0; + const fetch = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(input.toString()); + if ( + url.pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST" + ) { + sessionCount += 1; + return Response.json( + { + binding: { bindingId: `binding_${sessionCount}`, state: "attaching" }, + sessionId: `session_${sessionCount}`, + }, + { status: 201 }, + ); + } + if (url.pathname.endsWith("/heartbeat") && init?.method === "POST") { + heartbeatSignal = init?.signal ?? undefined; + heartbeatStarted.resolve(); + return await new Promise(() => undefined); + } + if (url.pathname.endsWith("/session") && init?.method === "DELETE") { + return Response.json({ ok: true }); + } + return new Response("not found", { status: 404 }); + }, + ); + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const writeErr = vi.fn(); + const service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387", fetch }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + projectRoot: dirname(dirname(projectConfigPath)), + writeErr, + }); + + await vi.waitFor(() => + expect( + fetch.mock.calls.some( + ([input, init]) => + new URL(input.toString()).pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST", + ), + ).toBe(true), + ); + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + await Promise.resolve(); + await heartbeatStarted.promise; + vi.advanceTimersToNextTimer(); + await vi.waitFor(() => + expect(writeErr).toHaveBeenCalledWith( + "Remote Project Binding heartbeat failed: Project Binding mutation timed out after 10000ms.\n", + ), + ); + type BindingLifecycleProbe = { start(): Promise }; + const projectBinding = (service as unknown as { projectBinding?: BindingLifecycleProbe }) + .projectBinding; + if (!projectBinding) throw new Error("Project Binding lifecycle was not created."); + await projectBinding.start(); + await vi.waitFor(() => expect(sessionCount).toBe(2)); + await service.close(); + + expect(heartbeatSignal?.aborted).toBe(true); + expect( + fetch.mock.calls.some( + ([input, init]) => + new URL(input.toString()).pathname.endsWith( + "/v1/attach/project-bindings/binding_2/session", + ) && init?.method === "DELETE", + ), + ).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("converges when a timed-out self-hosted DELETE later commits and retry gets 404", async () => { + vi.useFakeTimers(); + try { + const fixture = client(); + const deleteStarted = Promise.withResolvers(); + const releaseDelete = Promise.withResolvers(); + let deleteSignal: AbortSignal | undefined; + let deleteCalls = 0; + const fetch = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(input.toString()); + if ( + url.pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST" + ) { + return Response.json( + { + binding: { bindingId: "binding_1", state: "attaching" }, + sessionId: "session_1", + }, + { status: 201 }, + ); + } + if (url.pathname.endsWith("/v1/attach/project-bindings/binding_1/session")) { + deleteCalls += 1; + if (deleteCalls === 1) { + deleteSignal = init?.signal ?? undefined; + deleteStarted.resolve(); + return await releaseDelete.promise; + } + return new Response("not found", { status: 404 }); + } + return new Response("not found", { status: 404 }); + }, + ); + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387", fetch }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + projectRoot: dirname(dirname(projectConfigPath)), + }); + + await vi.waitFor(() => + expect( + fetch.mock.calls.some( + ([input, init]) => + new URL(input.toString()).pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST", + ), + ).toBe(true), + ); + const firstClose = service.close(); + await deleteStarted.promise; + vi.advanceTimersToNextTimer(); + await expect(firstClose).rejects.toThrow("timed out"); + expect(deleteSignal?.aborted).toBe(true); + + releaseDelete.resolve(Response.json({ ok: true })); + await Promise.resolve(); + await service.close(); + + expect(deleteCalls).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("coalesces self-hosted timer heartbeats while close remains the final mutation", async () => { + vi.useFakeTimers(); + try { + const fixture = client(); + const heartbeatStarted = Promise.withResolvers(); + const releaseHeartbeat = Promise.withResolvers(); + let heartbeatCalls = 0; + let deleteCalls = 0; + const localListeners = new Set<(tools: NativeCapletTool[]) => void>(); + const localService: NativeCapletsService = { + listTools: () => [], + execute: vi.fn(async () => undefined), + reload: vi.fn(async () => true), + onToolsChanged: (listener) => { + localListeners.add(listener); + return () => localListeners.delete(listener); + }, + close: vi.fn(async () => undefined), + }; + const fetch = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(input.toString()); + if ( + url.pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST" + ) { + return Response.json( + { + binding: { bindingId: "binding_1", state: "attaching" }, + sessionId: "session_1", + }, + { status: 201 }, + ); + } + if (url.pathname.endsWith("/heartbeat") && init?.method === "POST") { + heartbeatCalls += 1; + heartbeatStarted.resolve(); + await releaseHeartbeat.promise; + return Response.json({ ok: true }); + } + if ( + url.pathname.endsWith("/v1/attach/project-bindings/binding_1/session") && + init?.method === "DELETE" + ) { + deleteCalls += 1; + return Response.json({ ok: true }); + } + return new Response("not found", { status: 404 }); + }, + ); + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387", fetch }, + remoteClientFactory: vi.fn(() => fixture.api), + localServiceFactory: vi.fn(() => localService), + configPath, + projectConfigPath, + projectRoot: dirname(dirname(projectConfigPath)), + }); + for (const listener of localListeners) listener([]); + + await vi.waitFor(() => + expect( + fetch.mock.calls.some( + ([input, init]) => + new URL(input.toString()).pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST", + ), + ).toBe(true), + ); + vi.advanceTimersByTime(90_000); + await heartbeatStarted.promise; + releaseHeartbeat.resolve(); + await vi.advanceTimersByTimeAsync(0); + await service.close(); + + expect(heartbeatCalls).toBe(1); + expect(deleteCalls).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + it("retries Composite close after Project Binding cleanup fails", async () => { + const fixture = client(); + const localClose = vi.fn(async () => undefined); + const localService: NativeCapletsService = { + listTools: () => [], + execute: vi.fn(async () => undefined), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => undefined), + close: localClose, + }; + const service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => fixture.api), + localServiceFactory: vi.fn(() => localService), + }); + let cleanupAttempts = 0; + const bindingAdapter = { + start: vi.fn(async () => true), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => { + cleanupAttempts += 1; + if (cleanupAttempts === 1) throw new Error("delete timed out"); + }), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const lifecycle = new NativeProjectBindingLifecycle(bindingAdapter, ["alpha"]); + await lifecycle.start(); + type CompositeCloseProbe = { + projectBinding: NativeProjectBindingLifecycle | undefined; + }; + (service as unknown as CompositeCloseProbe).projectBinding = lifecycle; + + await expect(service.close()).rejects.toThrow("delete timed out"); + expect(fixture.api.close).not.toHaveBeenCalled(); + expect(localClose).not.toHaveBeenCalled(); + + await service.close(); + + expect(bindingAdapter.close).toHaveBeenCalledTimes(2); + expect(fixture.api.close).toHaveBeenCalledOnce(); + expect(localClose).toHaveBeenCalledOnce(); + }); + + it("fails fast for invalid remote config", () => { + expect(() => + createNativeCapletsService({ mode: "remote", remote: { url: "http://example.com" } }), + ).toThrow(/https/u); + }); +}); + +describe("NativeProjectBindingLifecycle", () => { + it("coalesces concurrent starts and latest-wins updates before cleanup", async () => { + const updateStarted = deferred(); + const releaseUpdate = deferred(); + const events: string[] = []; + let updateCount = 0; + const adapter = { + start: vi.fn(async (ids: string[]) => { + events.push(`start:${ids.join(",")}`); + }), + updateAllowedCapletIds: vi.fn(async (ids: string[]) => { + events.push(`update:${ids.join(",")}`); + if (updateCount++ === 0) { + updateStarted.resolve(); + await releaseUpdate.promise; + } + }), + prepareClose: vi.fn(() => { + events.push("prepare-close"); + }), + close: vi.fn(async () => { + events.push("close"); + }), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const lifecycle = new NativeProjectBindingLifecycle(adapter, ["beta", "alpha", "alpha"]); + + const firstStart = lifecycle.start(); + const secondStart = lifecycle.start(); + const duringStart = lifecycle.updateAllowedCapletIds(["charlie", "alpha", "charlie"]); + await Promise.all([firstStart, secondStart, duringStart]); + + expect(adapter.start).toHaveBeenCalledOnce(); + expect(adapter.start).toHaveBeenCalledWith(["alpha", "charlie"]); + expect(adapter.updateAllowedCapletIds).not.toHaveBeenCalled(); + + const updateA = lifecycle.updateAllowedCapletIds(["alpha"]); + await updateStarted.promise; + const updateB = lifecycle.updateAllowedCapletIds(["bravo"]); + const updateC = lifecycle.updateAllowedCapletIds(["charlie"]); + const closing = lifecycle.close(); + + expect(adapter.close).not.toHaveBeenCalled(); + releaseUpdate.resolve(); + await Promise.all([updateA, updateB, updateC, closing]); + + expect(events).toEqual([ + "start:alpha,charlie", + "update:alpha", + "prepare-close", + "update:charlie", + "close", + ]); + }); + + it("sends a newer accepted set after an in-flight update rejects", async () => { + const updateStarted = Promise.withResolvers(); + const releaseUpdate = Promise.withResolvers(); + const updates: string[][] = []; + const adapter = { + start: vi.fn(async () => undefined), + updateAllowedCapletIds: vi.fn(async (ids: string[]) => { + updates.push(ids); + if (ids[0] === "bravo") { + updateStarted.resolve(); + await releaseUpdate.promise; + throw new Error("bravo rejected"); + } + }), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const lifecycle = new NativeProjectBindingLifecycle(adapter, ["alpha"]); + + await lifecycle.start(); + const updateB = lifecycle.updateAllowedCapletIds(["bravo"]); + await updateStarted.promise; + const updateC = lifecycle.updateAllowedCapletIds(["charlie"]); + releaseUpdate.resolve(); + await expect(updateB).rejects.toThrow("bravo rejected"); + await expect(updateC).rejects.toThrow("bravo rejected"); + await vi.waitFor(() => expect(updates).toEqual([["bravo"], ["charlie"]])); + }); + + it("re-registers the newest accepted IDs after a self-hosted update disconnects", async () => { + let active = false; + const registrations: string[][] = []; + const updates: string[][] = []; + const adapter = { + start: vi.fn(async (ids: string[]) => { + registrations.push(ids); + active = true; + return true; + }), + updateAllowedCapletIds: vi.fn(async (ids: string[]) => { + updates.push(ids); + if (ids[0] === "bravo") { + active = false; + throw new Error("heartbeat disconnected"); + } + }), + hasActiveSession: () => active, + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const lifecycle = new NativeProjectBindingLifecycle(adapter, ["alpha"]); + + await lifecycle.start(); + await expect(lifecycle.updateAllowedCapletIds(["bravo"])).rejects.toThrow( + "heartbeat disconnected", + ); + await lifecycle.updateAllowedCapletIds(["charlie"]); + + expect(registrations).toEqual([["alpha"], ["charlie"]]); + expect(updates).toEqual([["bravo"]]); + }); + + it("cleans the old adapter when close wins a deferred replacement", async () => { + const updateStarted = Promise.withResolvers(); + const releaseUpdate = Promise.withResolvers(); + const events: string[] = []; + const old = { + start: vi.fn(async () => true), + updateAllowedCapletIds: vi.fn(async () => { + events.push("update"); + updateStarted.resolve(); + await releaseUpdate.promise; + }), + prepareClose: vi.fn(() => { + events.push("prepare-close"); + }), + close: vi.fn(async () => { + events.push("close"); + }), + dispose: vi.fn(() => { + events.push("dispose-old"); + }), + } satisfies ProjectBindingSessionAdapter; + const candidate = { + start: vi.fn(async () => { + events.push("start-candidate"); + }), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => { + events.push("close-candidate"); + }), + dispose: vi.fn(() => { + events.push("dispose-candidate"); + }), + } satisfies ProjectBindingSessionAdapter; + const lifecycle = new NativeProjectBindingLifecycle(old, ["alpha"]); + + await lifecycle.start(); + const updating = lifecycle.updateAllowedCapletIds(["bravo"]); + await updateStarted.promise; + const replacing = lifecycle.replace(candidate); + const closing = lifecycle.close(); + releaseUpdate.resolve(); + await Promise.all([updating, replacing, closing]); + + expect(events).toEqual([ + "update", + "prepare-close", + "prepare-close", + "close", + "dispose-old", + "dispose-candidate", + ]); + expect(candidate.start).not.toHaveBeenCalled(); + expect(candidate.close).not.toHaveBeenCalled(); + }); + + it("disposes a never-started binding when close wins the start turn", async () => { + const adapter = { + start: vi.fn(async () => undefined), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const lifecycle = new NativeProjectBindingLifecycle(adapter, ["alpha"]); + + await Promise.all([lifecycle.start(), lifecycle.close()]); + + expect(adapter.start).not.toHaveBeenCalled(); + expect(adapter.close).not.toHaveBeenCalled(); + expect(adapter.dispose).toHaveBeenCalledOnce(); + }); + + it("starts a replacement with IDs accepted while old cleanup is pending", async () => { + const cleanupStarted = Promise.withResolvers(); + const releaseCleanup = Promise.withResolvers(); + const old = { + start: vi.fn(async () => true), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => { + cleanupStarted.resolve(); + await releaseCleanup.promise; + }), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const candidate = { + start: vi.fn(async () => true), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const lifecycle = new NativeProjectBindingLifecycle(old, ["alpha"]); + + await lifecycle.start(); + const replacing = lifecycle.replace(candidate); + await cleanupStarted.promise; + await lifecycle.updateAllowedCapletIds(["charlie", "bravo", "charlie"]); + releaseCleanup.resolve(); + await replacing; + + expect(candidate.start).toHaveBeenCalledWith(["bravo", "charlie"]); + expect(candidate.updateAllowedCapletIds).not.toHaveBeenCalled(); + }); + + it("flushes IDs accepted while replacement registration is in flight", async () => { + const candidateStartEntered = Promise.withResolvers(); + const releaseCandidateStart = Promise.withResolvers(); + const old = { + start: vi.fn(async () => true), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const candidate = { + start: vi.fn(async () => { + candidateStartEntered.resolve(); + await releaseCandidateStart.promise; + return true; + }), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const lifecycle = new NativeProjectBindingLifecycle(old, ["alpha"]); + + await lifecycle.start(); + const replacing = lifecycle.replace(candidate); + await candidateStartEntered.promise; + await lifecycle.updateAllowedCapletIds(["bravo"]); + releaseCandidateStart.resolve(); + await replacing; + + expect(candidate.start).toHaveBeenCalledWith(["alpha"]); + expect(candidate.updateAllowedCapletIds).toHaveBeenCalledWith(["bravo"]); + }); + + it("retains a cleanup-failed owner and only starts a later replacement after retry", async () => { + let cleanupAttempts = 0; + const old = { + start: vi.fn(async () => undefined), + updateAllowedCapletIds: vi.fn(async () => undefined), + prepareClose: vi.fn(), + close: vi.fn(async () => { + cleanupAttempts += 1; + if (cleanupAttempts === 1) throw new Error("cleanup unavailable"); + }), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const rejectedCandidate = { + start: vi.fn(async () => undefined), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const replacement = { + start: vi.fn(async () => undefined), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const lifecycle = new NativeProjectBindingLifecycle(old, ["alpha"]); + + await lifecycle.start(); + await expect(lifecycle.replace(rejectedCandidate)).rejects.toThrow("cleanup unavailable"); + await lifecycle.updateAllowedCapletIds(["bravo"]); + + expect(old.updateAllowedCapletIds).not.toHaveBeenCalled(); + expect(rejectedCandidate.start).not.toHaveBeenCalled(); + expect(rejectedCandidate.close).not.toHaveBeenCalled(); + expect(rejectedCandidate.dispose).toHaveBeenCalledOnce(); + + await lifecycle.replace(replacement); + + expect(old.close).toHaveBeenCalledTimes(2); + expect(replacement.start).toHaveBeenCalledOnce(); + await lifecycle.close(); + }); + + it("disposes a candidate when its replacement start fails", async () => { + const old = { + start: vi.fn(async () => undefined), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const candidate = { + start: vi.fn(async () => { + throw new Error("candidate start failed"); + }), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const lifecycle = new NativeProjectBindingLifecycle(old, ["alpha"]); + + await lifecycle.start(); + await expect(lifecycle.replace(candidate)).rejects.toThrow("candidate start failed"); + await lifecycle.start(); + + expect(candidate.dispose).toHaveBeenCalledOnce(); + expect(candidate.start).toHaveBeenCalledOnce(); + }); + + it("retires the old adapter when remote teardown fails after binding cleanup", async () => { + const old = { + start: vi.fn(async () => undefined), + updateAllowedCapletIds: vi.fn(async () => undefined), + prepareClose: vi.fn(), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const candidate = { + start: vi.fn(async () => undefined), + updateAllowedCapletIds: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + dispose: vi.fn(), + } satisfies ProjectBindingSessionAdapter; + const lifecycle = new NativeProjectBindingLifecycle(old, ["alpha"]); + + await lifecycle.start(); + await expect( + lifecycle.replace(candidate, async () => { + throw new Error("old remote close failed"); + }), + ).rejects.toThrow("old remote close failed"); + await lifecycle.updateAllowedCapletIds(["bravo"]); + + expect(old.close).toHaveBeenCalledOnce(); + expect(old.dispose).toHaveBeenCalledOnce(); + expect(old.updateAllowedCapletIds).not.toHaveBeenCalled(); + expect(candidate.start).not.toHaveBeenCalled(); + expect(candidate.dispose).toHaveBeenCalledOnce(); }); }); diff --git a/packages/core/test/native.test.ts b/packages/core/test/native.test.ts index 52d12ccb..79209765 100644 --- a/packages/core/test/native.test.ts +++ b/packages/core/test/native.test.ts @@ -140,6 +140,37 @@ describe("native Caplets service", () => { } }); + it("executes accepted projection routes without rebuilding native tools", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + alpha: { + name: "Alpha", + description: "Search alpha project documents.", + command: process.execPath, + }, + }, + }); + dirs.push(dir); + const service = createNativeCapletsService({ configPath, projectConfigPath }); + await waitForInitialProjection(service); + + try { + const listTools = vi.spyOn(service, "listTools"); + await expect(service.execute("alpha", { operation: "inspect" })).resolves.toBeDefined(); + await expect(service.execute("alpha", { operation: "inspect" })).resolves.toBeDefined(); + + expect(listTools).not.toHaveBeenCalled(); + + await service.reload(); + listTools.mockClear(); + await expect(service.execute("alpha", { operation: "inspect" })).resolves.toBeDefined(); + + expect(listTools).not.toHaveBeenCalled(); + } finally { + await service.close(); + } + }); + it("uses daemon mode as a credential-free loopback remote client", async () => { const remoteOptions: unknown[] = []; const service = createNativeCapletsService({ @@ -1146,7 +1177,7 @@ describe("native Caplets service", () => { ), ); await expect(service.reload()).resolves.toBe(true); - expect(events).toEqual([[], ["gamma"]]); + expect(events).toEqual([["gamma"]]); unsubscribe(); writeFileSync( @@ -1164,7 +1195,7 @@ describe("native Caplets service", () => { ), ); await expect(service.reload()).resolves.toBe(true); - expect(events).toEqual([[], ["gamma"]]); + expect(events).toEqual([["gamma"]]); } finally { await service.close(); } @@ -1208,7 +1239,6 @@ describe("native Caplets service", () => { await expect(service.reload()).resolves.toBe(true); expect(events).toEqual([ - [], expect.arrayContaining(["fixture__echo", "fixture__list_resources", "fixture__get_prompt"]), ]); } finally { @@ -1263,7 +1293,7 @@ describe("native Caplets service", () => { ); await expect(service.reload()).resolves.toBe(false); - expect(events).toEqual([[], ["beta"]]); + expect(events).toEqual([["beta"]]); expect(errors.join("")).toContain("backend invalidation failed"); } finally { await service.close(); diff --git a/packages/core/test/project-binding-integration.test.ts b/packages/core/test/project-binding-integration.test.ts index 2aceec84..4560a2ef 100644 --- a/packages/core/test/project-binding-integration.test.ts +++ b/packages/core/test/project-binding-integration.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, writeFileSync, @@ -12,6 +13,7 @@ import { join, resolve } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ManagedMutagenProjectSync } from "../src/project-binding/mutagen"; import { ProjectBindingWorkspaceStore } from "../src/project-binding/workspaces"; +import type { ProjectBindingLease } from "../src/project-binding"; import { createNativeCapletsService } from "../src/native/service"; import type { RemoteCapletsClient } from "../src/native/remote"; @@ -62,6 +64,150 @@ describe("Project Binding integration", () => { expect(readFileSync(join(workspace.project, "build.js"), "utf8")).toContain("process.cwd()"); }); + it("reclaims a workspace after the server records its binding lease as terminal", async () => { + const stateRoot = mkdtempSync(join(tmpdir(), "caplets-project-binding-state-")); + dirs.push(stateRoot); + const now = new Date("2026-07-10T12:00:00.000Z"); + const workspaces = new ProjectBindingWorkspaceStore({ + root: stateRoot, + now: () => now, + inactiveWorkspaceTtlMs: 0, + }); + const workspace = await workspaces.ensureWorkspace({ + projectFingerprint: "sha256-terminal", + projectRoot: fixtureProjectRoot, + lastActiveAt: now.toISOString(), + }); + const lease: ProjectBindingLease = { + bindingId: "bind_terminal", + projectFingerprint: "sha256-terminal", + state: "ended", + active: false, + updatedAt: now.toISOString(), + expiresAt: now.toISOString(), + }; + await workspaces.writeLease(lease); + + await expect(workspaces.cleanup()).resolves.toEqual({ + expiredLeases: [workspace.lease(lease.bindingId)], + deletedWorkspaces: [workspace.root], + retainedWorkspaces: [], + }); + expect(existsSync(workspace.root)).toBe(false); + }); + + it("prunes an expired active restart lease while retaining a nonexpired active lease", async () => { + const stateRoot = mkdtempSync(join(tmpdir(), "caplets-project-binding-state-")); + dirs.push(stateRoot); + const now = new Date("2026-07-10T12:00:00.000Z"); + const workspaces = new ProjectBindingWorkspaceStore({ + root: stateRoot, + now: () => now, + inactiveWorkspaceTtlMs: 0, + }); + const expired = await workspaces.ensureWorkspace({ + projectFingerprint: "sha256-expired-restart", + projectRoot: fixtureProjectRoot, + lastActiveAt: now.toISOString(), + }); + const active = await workspaces.ensureWorkspace({ + projectFingerprint: "sha256-active-restart", + projectRoot: fixtureProjectRoot, + lastActiveAt: now.toISOString(), + }); + await workspaces.writeLease({ + bindingId: "bind_expired_restart", + projectFingerprint: "sha256-expired-restart", + state: "ready", + active: true, + updatedAt: now.toISOString(), + expiresAt: now.toISOString(), + }); + await workspaces.writeLease({ + bindingId: "bind_active_restart", + projectFingerprint: "sha256-active-restart", + state: "ready", + active: true, + updatedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 60_000).toISOString(), + }); + + await expect(workspaces.cleanup()).resolves.toEqual({ + expiredLeases: [expired.lease("bind_expired_restart")], + deletedWorkspaces: [expired.root], + retainedWorkspaces: [active.root], + }); + expect(existsSync(expired.root)).toBe(false); + await expect(workspaces.listLeases("sha256-active-restart")).resolves.toEqual([ + expect.objectContaining({ bindingId: "bind_active_restart", active: true }), + ]); + }); + + it("isolates corrupt workspace JSON while reclaiming valid expired leases", async () => { + const stateRoot = mkdtempSync(join(tmpdir(), "caplets-project-binding-state-")); + dirs.push(stateRoot); + const now = new Date("2026-07-10T12:00:00.000Z"); + const workspaces = new ProjectBindingWorkspaceStore({ + root: stateRoot, + now: () => now, + inactiveWorkspaceTtlMs: 0, + }); + const valid = await workspaces.ensureWorkspace({ + projectFingerprint: "sha256-valid-expired", + projectRoot: fixtureProjectRoot, + lastActiveAt: now.toISOString(), + }); + await workspaces.writeLease({ + bindingId: "bind_valid_expired", + projectFingerprint: "sha256-valid-expired", + state: "ended", + active: false, + updatedAt: now.toISOString(), + expiresAt: now.toISOString(), + }); + const corrupt = await workspaces.ensureWorkspace({ + projectFingerprint: "sha256-corrupt", + projectRoot: fixtureProjectRoot, + }); + writeFileSync(corrupt.metadata, "{", "utf8"); + writeFileSync(corrupt.lease("bind_corrupt"), "{", "utf8"); + + await expect(workspaces.listLeases("sha256-corrupt")).resolves.toEqual([]); + await expect(workspaces.cleanup()).resolves.toEqual( + expect.objectContaining({ expiredLeases: [valid.lease("bind_valid_expired")] }), + ); + expect(existsSync(valid.root)).toBe(false); + }); + + it("atomically finalizes managed lease writes without leaving temporary entries", async () => { + const stateRoot = mkdtempSync(join(tmpdir(), "caplets-project-binding-state-")); + dirs.push(stateRoot); + const workspaces = new ProjectBindingWorkspaceStore({ root: stateRoot }); + const workspace = await workspaces.ensureWorkspace({ + projectFingerprint: "sha256-atomic", + projectRoot: fixtureProjectRoot, + }); + const lease: ProjectBindingLease = { + bindingId: "bind_atomic", + projectFingerprint: "sha256-atomic", + state: "ready", + active: true, + updatedAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-07-10T12:01:00.000Z", + }; + await workspaces.writeLease(lease); + await workspaces.writeLease({ ...lease, state: "ended", active: false }); + + await expect(workspaces.listLeases("sha256-atomic")).resolves.toEqual([ + expect.objectContaining({ bindingId: lease.bindingId, state: "ended", active: false }), + ]); + expect(JSON.parse(readFileSync(workspace.lease(lease.bindingId), "utf8"))).toMatchObject({ + state: "ended", + active: false, + }); + expect(readdirSync(workspace.leases)).toEqual([`${lease.bindingId}.json`]); + }); + it("suppresses local overlay duplicates while remote-only Caplets execute remotely", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-project-binding-native-")); dirs.push(dir); diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index c3d3106c..8c303374 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; @@ -1034,17 +1035,1294 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("prunes an expired active Project Binding restart lease when HTTP starts", async () => { + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const now = new Date("2026-07-10T12:00:00.000Z"); + const workspaces = new ProjectBindingWorkspaceStore({ + root: workspaceRoot, + now: () => now, + inactiveWorkspaceTtlMs: 0, + }); + await workspaces.ensureWorkspace({ + projectFingerprint: "sha256-restart-expired", + projectRoot: "/repo", + lastActiveAt: now.toISOString(), + }); + await workspaces.writeLease({ + bindingId: "bind_restart_expired", + projectFingerprint: "sha256-restart-expired", + state: "ready", + active: true, + updatedAt: now.toISOString(), + expiresAt: now.toISOString(), + }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + + try { + await withTimeout( + waitFor(async () => { + await expect(workspaces.listLeases("sha256-restart-expired")).resolves.toEqual([]); + }), + "prune restart lease at HTTP startup", + ); + } finally { + await app.closeCapletsSessions(); + await engine.close(); + } + }); + + it("bounds workspace cleanup to one startup-or-prune run while cleanup is pending", async () => { + vi.useFakeTimers(); + let cleanup: (() => Promise) | undefined; + try { + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new DeferredWorkspaceCleanupStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + cleanup = async () => { + workspaces.releaseCleanup.resolve(); + await app.closeCapletsSessions(); + await engine.close(); + }; + + expect(workspaces.cleanupCalls).toBe(1); + await vi.advanceTimersByTimeAsync(3 * 60_000); + expect(workspaces.cleanupCalls).toBe(1); + workspaces.releaseCleanup.resolve(); + await vi.advanceTimersByTimeAsync(0); + + await cleanup(); + cleanup = undefined; + } finally { + await cleanup?.(); + vi.useRealTimers(); + } + }); + it("expires inactive Project Binding sessions from in-memory lookups", async () => { vi.useFakeTimers(); let cleanup: (() => Promise) | undefined; try { - vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z")); + vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z")); + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: new ProjectBindingWorkspaceStore({ root: workspaceRoot }), + }); + cleanup = async () => { + await app.closeCapletsSessions(); + await engine.close(); + }; + + const session = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/sessions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }, + ); + const created = (await session.json()) as { + binding: { bindingId: string }; + sessionId: string; + }; + await vi.advanceTimersByTimeAsync(60_000); + + const heartbeat = await app.request( + `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/heartbeat`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId: created.sessionId, state: "ready", syncState: "idle" }), + }, + ); + expect(heartbeat.status).toBe(404); + const status = await app.request( + `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/status`, + ); + await expect(status.json()).resolves.toMatchObject({ + bindingId: created.binding.bindingId, + state: "not_attached", + }); + + await cleanup(); + cleanup = undefined; + } finally { + await cleanup?.(); + vi.useRealTimers(); + } + }); + + it("terminalizes a post-TTL heartbeat before the next Project Binding prune tick", async () => { + vi.useFakeTimers(); + let cleanup: (() => Promise) | undefined; + try { + vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z")); + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new ProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + cleanup = async () => { + await app.closeCapletsSessions(); + await engine.close(); + }; + + const session = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/sessions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }, + ); + const created = (await session.json()) as { + binding: { bindingId: string }; + sessionId: string; + }; + vi.setSystemTime(new Date("2026-06-25T12:01:00.001Z")); + + const heartbeat = await app.request( + `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/heartbeat`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId: created.sessionId, state: "ready", syncState: "idle" }), + }, + ); + expect(heartbeat.status).toBe(404); + await vi.advanceTimersByTimeAsync(0); + + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest("development_unauthenticated", "sha256_repo"), + ), + ).resolves.toEqual([ + expect.objectContaining({ bindingId: created.binding.bindingId, active: false }), + ]); + + await cleanup(); + cleanup = undefined; + } finally { + await cleanup?.(); + vi.useRealTimers(); + } + }); + + it("keeps prune racing a deferred heartbeat terminal rather than reviving its lease", async () => { + vi.useFakeTimers(); + let cleanup: (() => Promise) | undefined; + try { + vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z")); + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + cleanup = async () => { + await app.closeCapletsSessions(); + await engine.close(); + }; + + const session = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/sessions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }, + ); + const created = (await session.json()) as { + binding: { bindingId: string }; + sessionId: string; + }; + workspaces.deferNextWrite(); + const heartbeat = app.request( + `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/heartbeat`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId: created.sessionId, state: "ready", syncState: "idle" }), + }, + ); + await workspaces.nextWriteStarted; + await vi.advanceTimersByTimeAsync(60_000); + workspaces.releaseNextWrite(); + + expect((await heartbeat).status).toBe(403); + await vi.advanceTimersByTimeAsync(0); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest("development_unauthenticated", "sha256_repo"), + ), + ).resolves.toEqual([ + expect.objectContaining({ bindingId: created.binding.bindingId, active: false }), + ]); + const status = await app.request( + `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/status`, + ); + await expect(status.json()).resolves.toMatchObject({ + bindingId: created.binding.bindingId, + state: "not_attached", + }); + + await cleanup(); + cleanup = undefined; + } finally { + await cleanup?.(); + vi.useRealTimers(); + } + }); + + it("deduplicates ownerless terminal cleanup while a prune write is pending and retries after failure", async () => { + vi.useFakeTimers(); + let cleanup: (() => Promise) | undefined; + try { + vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z")); + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + cleanup = async () => { + await app.closeCapletsSessions(); + await engine.close(); + }; + + const session = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/sessions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }, + ); + const created = (await session.json()) as { + binding: { bindingId: string; expiresAt: string }; + }; + const statusUrl = `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/status`; + workspaces.deferNextWrite(); + workspaces.failNextWrite(); + await vi.advanceTimersByTimeAsync(60_000); + await workspaces.nextWriteStarted; + await app.request(statusUrl); + await app.request(statusUrl); + await vi.advanceTimersByTimeAsync(60_000); + expect(workspaces.writeLeases).toHaveLength(1); + + workspaces.releaseNextWrite(); + await vi.advanceTimersByTimeAsync(0); + expect(workspaces.writeLeases).toHaveLength(2); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest("development_unauthenticated", "sha256_repo"), + ), + ).resolves.toEqual([]); + + await app.request(statusUrl); + await vi.advanceTimersByTimeAsync(0); + expect(workspaces.writeLeases).toHaveLength(3); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest("development_unauthenticated", "sha256_repo"), + ), + ).resolves.toEqual([ + expect.objectContaining({ + bindingId: created.binding.bindingId, + active: false, + state: "ended", + }), + ]); + + await cleanup(); + cleanup = undefined; + } finally { + await cleanup?.(); + vi.useRealTimers(); + } + }); + + it("bounds concurrent HTTP heartbeats to the in-flight write and the latest pending state", async () => { + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + + try { + const session = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/sessions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }, + ); + const created = (await session.json()) as { + binding: { bindingId: string }; + sessionId: string; + }; + const heartbeatUrl = `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/heartbeat`; + workspaces.deferNextWrite(); + const first = app.request(heartbeatUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId: created.sessionId, state: "ready", syncState: "idle" }), + }); + await workspaces.nextWriteStarted; + const pending = [ + app.request(heartbeatUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sessionId: created.sessionId, + state: "offline", + syncState: "failed", + }), + }), + app.request(heartbeatUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sessionId: created.sessionId, + state: "degraded", + syncState: "failed", + }), + }), + ]; + await new Promise((resolve) => setImmediate(resolve)); + workspaces.releaseNextWrite(); + + expect((await first).status).toBe(200); + await expect(Promise.all(pending)).resolves.toEqual([ + expect.objectContaining({ status: 200 }), + expect.objectContaining({ status: 200 }), + ]); + expect(workspaces.writeLeases).toHaveLength(3); + expect(workspaces.writeLeases.at(-1)).toMatchObject({ + bindingId: created.binding.bindingId, + state: "degraded", + }); + } finally { + await app.closeCapletsSessions(); + await engine.close(); + } + }); + + it("makes shutdown terminal when a Project Binding heartbeat write is in flight", async () => { + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + + try { + const session = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/sessions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }, + ); + const created = (await session.json()) as { + binding: { bindingId: string }; + sessionId: string; + }; + workspaces.deferNextWrite(); + const heartbeat = app.request( + `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/heartbeat`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId: created.sessionId, state: "ready", syncState: "idle" }), + }, + ); + await workspaces.nextWriteStarted; + const closing = app.closeCapletsSessions(); + workspaces.releaseNextWrite(); + + expect((await heartbeat).status).toBe(403); + await closing; + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest("development_unauthenticated", "sha256_repo"), + ), + ).resolves.toEqual([ + expect.objectContaining({ bindingId: created.binding.bindingId, active: false }), + ]); + } finally { + await engine.close(); + } + }); + + it("does not acknowledge an HTTP Project Binding end when terminal lease persistence fails", async () => { + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new FailingProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + + try { + const session = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/sessions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }, + ); + const created = (await session.json()) as { + binding: { bindingId: string }; + }; + workspaces.failNextWrite(); + + const ended = await app.request( + `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/session`, + { method: "DELETE", headers: { "content-type": "application/json" }, body: "{}" }, + ); + expect(ended.status).toBe(500); + await app.closeCapletsSessions(); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest("development_unauthenticated", "sha256_repo"), + ), + ).resolves.toEqual([ + expect.objectContaining({ bindingId: created.binding.bindingId, active: false }), + ]); + } finally { + await engine.close(); + } + }); + + it("rejects failed shutdown cleanup and lets a later shutdown retry terminal persistence", async () => { + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new FailingProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + + try { + const session = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/sessions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }, + ); + const created = (await session.json()) as { + binding: { bindingId: string }; + }; + workspaces.failNextWrite(); + + await expect(app.closeCapletsSessions()).rejects.toThrow("terminal lease write failed"); + await app.closeCapletsSessions(); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest("development_unauthenticated", "sha256_repo"), + ), + ).resolves.toEqual([ + expect.objectContaining({ bindingId: created.binding.bindingId, active: false }), + ]); + } finally { + await engine.close(); + } + }); + + it("terminalizes a staged session lease when shutdown wins creation", async () => { + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + + try { + workspaces.deferNextWrite(); + const creating = app.request("http://127.0.0.1:5387/v1/attach/project-bindings/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }); + await workspaces.nextWriteStarted; + const closing = app.closeCapletsSessions(); + workspaces.releaseNextWrite(); + + expect((await creating).status).toBe(503); + await closing; + const rejectedAfterShutdown = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/sessions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + projectRoot: "/repo", + projectFingerprint: "sha256_after_shutdown", + }), + }, + ); + expect(rejectedAfterShutdown.status).toBe(503); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest("development_unauthenticated", "sha256_repo"), + ), + ).resolves.toEqual([expect.objectContaining({ active: false, state: "ended" })]); + } finally { + await engine.close(); + } + }); + + it("accepts Project Binding WebSocket connections for owned sessions", async () => { + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: new ProjectBindingWorkspaceStore({ root: workspaceRoot }), + }); + const server = await withTimeout(startTestHttpServer(app), "start test HTTP server"); + + try { + const session = await withTimeout( + fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }), + "create Project Binding session", + ); + expect(session.status).toBe(201); + const created = (await session.json()) as { + binding: { bindingId: string; state: string; syncState: string }; + sessionId: string; + }; + const socket = new WebSocket( + `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}&projectFingerprint=sha256_repo`, + ); + try { + await withTimeout(waitForSocketOpen(socket), "open Project Binding WebSocket"); + await expect( + withTimeout(nextSocketJson(socket), "receive Project Binding ready"), + ).resolves.toMatchObject({ + type: "ready", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + syncState: "pending", + }); + socket.send( + JSON.stringify({ + type: "heartbeat", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + state: "ready", + syncState: "idle", + }), + ); + await withTimeout( + waitFor(async () => { + const status = await fetch( + `${server.origin}/v1/attach/project-bindings/${created.binding.bindingId}/status`, + ); + const payload = (await status.json()) as { state: string; syncState: string }; + expect(payload).toMatchObject({ state: "ready", syncState: "idle" }); + }), + "observe Project Binding heartbeat", + ); + } finally { + socket.terminate(); + } + } finally { + await withTimeout(server.close(), "close test HTTP server"); + await engine.close(); + } + }); + + it("ends Project Binding sessions when sockets close without an explicit end message", async () => { + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: new ProjectBindingWorkspaceStore({ root: workspaceRoot }), + }); + const server = await withTimeout(startTestHttpServer(app), "start test HTTP server"); + + try { + const session = await withTimeout( + fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }), + "create Project Binding session", + ); + expect(session.status).toBe(201); + const created = (await session.json()) as { + binding: { bindingId: string }; + sessionId: string; + }; + const socket = new WebSocket( + `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}&projectFingerprint=sha256_repo`, + ); + await withTimeout(waitForSocketOpen(socket), "open Project Binding WebSocket"); + await withTimeout(nextSocketJson(socket), "receive Project Binding ready"); + socket.close(); + + await withTimeout( + waitFor(async () => { + const status = await fetch( + `${server.origin}/v1/attach/project-bindings/${created.binding.bindingId}/status`, + ); + await expect(status.json()).resolves.toMatchObject({ + bindingId: created.binding.bindingId, + state: "not_attached", + }); + }), + "observe Project Binding close cleanup", + ); + } finally { + await withTimeout(server.close(), "close test HTTP server"); + await engine.close(); + } + }); + + it("keeps two-socket heartbeat and end races terminal in either ordering", async () => { + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + const server = await withTimeout(startTestHttpServer(app), "start test HTTP server"); + + try { + for (const heartbeatFirst of [true, false]) { + const session = await fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }); + const created = (await session.json()) as { + binding: { bindingId: string }; + sessionId: string; + }; + const socketUrl = + `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=` + + `${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}`; + const first = new WebSocket(socketUrl); + const second = new WebSocket(socketUrl); + try { + await withTimeout(waitForSocketOpen(first), "open first Project Binding socket"); + await withTimeout(waitForSocketOpen(second), "open second Project Binding socket"); + await withTimeout(nextSocketJson(first), "receive first Project Binding ready"); + await withTimeout(nextSocketJson(second), "receive second Project Binding ready"); + + const heartbeatSocket = heartbeatFirst ? first : second; + const endingSocket = heartbeatFirst ? second : first; + const heartbeatMessages: unknown[] = []; + heartbeatSocket.on("message", (data) => { + heartbeatMessages.push(JSON.parse(data.toString()) as unknown); + }); + const heartbeat = JSON.stringify({ + type: "heartbeat", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + state: "ready", + syncState: "idle", + }); + const end = JSON.stringify({ + type: "end", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + reason: { code: "completed", message: "Project Binding completed." }, + }); + + workspaces.deferNextWrite(); + if (heartbeatFirst) { + heartbeatSocket.send(heartbeat); + await withTimeout(workspaces.nextWriteStarted, "start deferred first heartbeat"); + const ended = nextSocketJson(endingSocket); + endingSocket.send(end); + workspaces.releaseNextWrite(); + await expect(withTimeout(ended, "receive Project Binding end")).resolves.toMatchObject({ + type: "ended", + }); + const staleClose = waitForSocketClose(heartbeatSocket); + heartbeatSocket.send(heartbeat); + await expect( + withTimeout(staleClose, "close stale first Project Binding socket"), + ).resolves.toMatchObject({ code: 1008 }); + } else { + const ended = nextSocketJson(endingSocket); + endingSocket.send(end); + await withTimeout(workspaces.nextWriteStarted, "start deferred Project Binding end"); + const staleClose = waitForSocketClose(heartbeatSocket); + heartbeatSocket.send(heartbeat); + workspaces.releaseNextWrite(); + await expect(withTimeout(ended, "receive Project Binding end")).resolves.toMatchObject({ + type: "ended", + }); + await expect( + withTimeout(staleClose, "close stale second Project Binding socket"), + ).resolves.toMatchObject({ code: 1008 }); + } + + expect(heartbeatMessages).toEqual([]); + const status = await app.request( + `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/status`, + ); + await expect(status.json()).resolves.toMatchObject({ + bindingId: created.binding.bindingId, + state: "not_attached", + }); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest( + "development_unauthenticated", + "sha256_repo", + ), + ), + ).resolves.toContainEqual( + expect.objectContaining({ bindingId: created.binding.bindingId, active: false }), + ); + } finally { + first.terminate(); + second.terminate(); + } + } + } finally { + await withTimeout(server.close(), "close test HTTP server"); + await engine.close(); + } + }, 30_000); + + it("makes a peer socket close terminal against a deferred heartbeat", async () => { + const { engine } = testEngine(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + projectBindingWorkspaceStore: workspaces, + }); + const server = await withTimeout(startTestHttpServer(app), "start test HTTP server"); + + try { + const session = await fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }); + const created = (await session.json()) as { + binding: { bindingId: string }; + sessionId: string; + }; + const socketUrl = + `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=` + + `${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}`; + const heartbeatSocket = new WebSocket(socketUrl); + const peer = new WebSocket(socketUrl); + try { + await withTimeout(waitForSocketOpen(heartbeatSocket), "open heartbeat socket"); + await withTimeout(waitForSocketOpen(peer), "open peer socket"); + await withTimeout(nextSocketJson(heartbeatSocket), "receive heartbeat socket ready"); + await withTimeout(nextSocketJson(peer), "receive peer socket ready"); + workspaces.deferNextWrite(); + heartbeatSocket.send( + JSON.stringify({ + type: "heartbeat", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + state: "ready", + syncState: "idle", + }), + ); + await withTimeout(workspaces.nextWriteStarted, "start deferred heartbeat"); + const peerClosed = waitForSocketClose(peer); + peer.close(); + await withTimeout(peerClosed, "close peer socket"); + const firstIoTurn = Promise.withResolvers(); + setImmediate(firstIoTurn.resolve); + await firstIoTurn.promise; + const secondIoTurn = Promise.withResolvers(); + setImmediate(secondIoTurn.resolve); + await secondIoTurn.promise; + workspaces.releaseNextWrite(); + + await withTimeout( + waitFor(async () => { + const status = await app.request( + `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/status`, + ); + await expect(status.json()).resolves.toMatchObject({ + bindingId: created.binding.bindingId, + state: "not_attached", + }); + }), + "observe peer close terminal cleanup", + ); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest("development_unauthenticated", "sha256_repo"), + ), + ).resolves.toContainEqual( + expect.objectContaining({ bindingId: created.binding.bindingId, active: false }), + ); + const staleClose = waitForSocketClose(heartbeatSocket); + heartbeatSocket.send( + JSON.stringify({ + type: "heartbeat", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + state: "ready", + syncState: "idle", + }), + ); + await expect( + withTimeout(staleClose, "close stale heartbeat socket"), + ).resolves.toMatchObject({ + code: 1008, + }); + } finally { + heartbeatSocket.terminate(); + peer.terminate(); + } + } finally { + await withTimeout(server.close(), "close test HTTP server"); + await engine.close(); + } + }, 30_000); + + it("authenticates Project Binding WebSocket connections with bearer subprotocols", async () => { + const { engine } = testEngine(); + const store = remoteCredentialStore(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const app = createHttpServeApp(httpOptions({ auth: { type: "remote_credentials" } }), engine, { + writeErr: () => {}, + remoteCredentialStore: store, + projectBindingWorkspaceStore: new ProjectBindingWorkspaceStore({ root: workspaceRoot }), + }); + const server = await withTimeout(startTestHttpServer(app), "start test HTTP server"); + const credentials = pairedClient(store, `${server.origin}/`); + + try { + const session = await withTimeout( + fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { + method: "POST", + headers: { + authorization: `Bearer ${credentials.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }), + "create authenticated Project Binding session", + ); + expect(session.status).toBe(201); + const created = (await session.json()) as { + binding: { bindingId: string }; + sessionId: string; + }; + const socket = new WebSocket( + `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}&projectFingerprint=sha256_repo`, + [ + "caplets.project-binding.v1", + `caplets.bearer.${Buffer.from(credentials.accessToken).toString("base64url")}`, + ], + ); + try { + await withTimeout(waitForSocketOpen(socket), "open authenticated Project Binding socket"); + await expect( + withTimeout(nextSocketJson(socket), "receive authenticated Project Binding ready"), + ).resolves.toMatchObject({ + type: "ready", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + }); + } finally { + socket.terminate(); + } + } finally { + await withTimeout(server.close(), "close test HTTP server"); + await engine.close(); + } + }); + + it("keeps a socket authorized across token rotation", async () => { + const { engine } = testEngine(); + const store = remoteCredentialStore(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new ProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions({ auth: { type: "remote_credentials" } }), engine, { + writeErr: () => {}, + remoteCredentialStore: store, + projectBindingWorkspaceStore: workspaces, + }); + const server = await withTimeout(startTestHttpServer(app), "start test HTTP server"); + const credentials = pairedClient(store, `${server.origin}/`); + + try { + const session = await withTimeout( + fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { + method: "POST", + headers: { + authorization: `Bearer ${credentials.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }), + "create authenticated Project Binding session", + ); + const created = (await session.json()) as { + binding: { bindingId: string }; + sessionId: string; + }; + const socket = new WebSocket( + `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}&projectFingerprint=sha256_repo`, + [ + "caplets.project-binding.v1", + `caplets.bearer.${Buffer.from(credentials.accessToken).toString("base64url")}`, + ], + ); + try { + await withTimeout(waitForSocketOpen(socket), "open authenticated Project Binding socket"); + await withTimeout(nextSocketJson(socket), "receive Project Binding ready"); + + const rotated = store.refreshClientCredentials({ + hostUrl: `${server.origin}/`, + refreshToken: credentials.refreshToken, + }); + socket.send( + JSON.stringify({ + type: "heartbeat", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + state: "ready", + syncState: "idle", + }), + ); + await withTimeout( + waitFor(async () => { + const status = await fetch( + `${server.origin}/v1/attach/project-bindings/${created.binding.bindingId}/status`, + { headers: { authorization: `Bearer ${rotated.accessToken}` } }, + ); + await expect(status.json()).resolves.toMatchObject({ + state: "ready", + syncState: "idle", + }); + }), + "observe heartbeat after token rotation", + ); + } finally { + socket.terminate(); + } + } finally { + await withTimeout(server.close(), "close test HTTP server"); + await engine.close(); + } + }); + + it("rechecks role loss after a queued first heartbeat commits but before the next mutation starts", async () => { + const { engine } = testEngine(); + const store = remoteCredentialStore(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions({ auth: { type: "remote_credentials" } }), engine, { + writeErr: () => {}, + remoteCredentialStore: store, + projectBindingWorkspaceStore: workspaces, + }); + const server = await withTimeout(startTestHttpServer(app), "start test HTTP server"); + const credentials = pairedClient(store, `${server.origin}/`); + + try { + const session = await fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { + method: "POST", + headers: { + authorization: `Bearer ${credentials.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }); + const created = (await session.json()) as { + binding: { bindingId: string }; + sessionId: string; + }; + const socket = new WebSocket( + `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}`, + [ + "caplets.project-binding.v1", + `caplets.bearer.${Buffer.from(credentials.accessToken).toString("base64url")}`, + ], + ); + try { + await withTimeout(waitForSocketOpen(socket), "open queued authorization socket"); + await withTimeout(nextSocketJson(socket), "receive Project Binding ready"); + workspaces.deferNextWrite(); + workspaces.runAfterNextWritePostCommit(() => { + store.changeClientRole(credentials.clientId, "operator"); + }); + const closed = waitForSocketClose(socket); + socket.send( + JSON.stringify({ + type: "heartbeat", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + state: "ready", + syncState: "idle", + }), + ); + await withTimeout(workspaces.nextWriteStarted, "start queued first heartbeat"); + socket.send( + JSON.stringify({ + type: "heartbeat", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + state: "syncing", + syncState: "syncing", + }), + ); + const firstNodeIoTurn = Promise.withResolvers(); + setImmediate(firstNodeIoTurn.resolve); + await firstNodeIoTurn.promise; + expect(workspaces.writeLeases).toHaveLength(1); + const secondNodeIoTurn = Promise.withResolvers(); + setImmediate(secondNodeIoTurn.resolve); + await secondNodeIoTurn.promise; + expect(workspaces.writeLeases).toHaveLength(1); + workspaces.releaseNextWrite(); + + await expect(withTimeout(closed, "close demoted queued socket")).resolves.toMatchObject({ + code: 1008, + }); + expect(workspaces.writeLeases).toHaveLength(3); + expect(workspaces.writeLeases[1]).toMatchObject({ + bindingId: created.binding.bindingId, + state: "ready", + active: true, + }); + expect(workspaces.writeLeases).not.toContainEqual( + expect.objectContaining({ bindingId: created.binding.bindingId, state: "syncing" }), + ); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest(credentials.clientId, "sha256_repo"), + ), + ).resolves.toEqual([ + expect.objectContaining({ + bindingId: created.binding.bindingId, + state: "ended", + active: false, + expiresAt: workspaces.writeLeases[1]!.expiresAt, + }), + ]); + } finally { + socket.terminate(); + } + } finally { + await withTimeout(server.close(), "close test HTTP server"); + await engine.close(); + } + }); + + it("rejects in-flight heartbeat state after post-write durable role loss", async () => { + const { engine } = testEngine(); + const store = remoteCredentialStore(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions({ auth: { type: "remote_credentials" } }), engine, { + writeErr: () => {}, + remoteCredentialStore: store, + projectBindingWorkspaceStore: workspaces, + }); + const server = await withTimeout(startTestHttpServer(app), "start test HTTP server"); + const credentials = pairedClient(store, `${server.origin}/`); + + try { + const session = await fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { + method: "POST", + headers: { + authorization: `Bearer ${credentials.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }); + const created = (await session.json()) as { + binding: { bindingId: string; expiresAt: string }; + sessionId: string; + }; + const socket = new WebSocket( + `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}`, + [ + "caplets.project-binding.v1", + `caplets.bearer.${Buffer.from(credentials.accessToken).toString("base64url")}`, + ], + ); + try { + await withTimeout(waitForSocketOpen(socket), "open post-write authorization socket"); + await withTimeout(nextSocketJson(socket), "receive Project Binding ready"); + workspaces.deferNextWrite(); + const closed = waitForSocketClose(socket); + socket.send( + JSON.stringify({ + type: "heartbeat", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + state: "syncing", + syncState: "syncing", + }), + ); + await withTimeout(workspaces.nextWriteStarted, "start in-flight candidate lease write"); + store.changeClientRole(credentials.clientId, "operator"); + workspaces.releaseNextWrite(); + + await expect(withTimeout(closed, "close post-write demoted socket")).resolves.toMatchObject( + { + code: 1008, + }, + ); + expect(workspaces.writeLeases).toHaveLength(3); + expect(workspaces.writeLeases[1]).toMatchObject({ + bindingId: created.binding.bindingId, + state: "syncing", + active: true, + }); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest(credentials.clientId, "sha256_repo"), + ), + ).resolves.toEqual([ + expect.objectContaining({ + bindingId: created.binding.bindingId, + state: "ended", + active: false, + expiresAt: created.binding.expiresAt, + }), + ]); + } finally { + socket.terminate(); + } + } finally { + await withTimeout(server.close(), "close test HTTP server"); + await engine.close(); + } + }); + + it("does not return a successful HTTP end after post-write role loss", async () => { + const { engine } = testEngine(); + const store = remoteCredentialStore(); + const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); + dirs.push(workspaceRoot); + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions({ auth: { type: "remote_credentials" } }), engine, { + writeErr: () => {}, + remoteCredentialStore: store, + projectBindingWorkspaceStore: workspaces, + }); + const credentials = pairedClient(store); + + try { + const session = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/sessions", + { + method: "POST", + headers: { + authorization: `Bearer ${credentials.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }, + ); + const created = (await session.json()) as { + binding: { bindingId: string; expiresAt: string }; + }; + workspaces.deferNextWrite(); + const ending = app.request( + `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/session`, + { + method: "DELETE", + headers: { + authorization: `Bearer ${credentials.accessToken}`, + "content-type": "application/json", + }, + body: "{}", + }, + ); + await workspaces.nextWriteStarted; + store.changeClientRole(credentials.clientId, "operator"); + workspaces.releaseNextWrite(); + + expect((await ending).status).toBe(403); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest(credentials.clientId, "sha256_repo"), + ), + ).resolves.toEqual([ + expect.objectContaining({ + bindingId: created.binding.bindingId, + state: "ended", + active: false, + expiresAt: created.binding.expiresAt, + }), + ]); + } finally { + await engine.close(); + } + }); + + it("does not return a successful deferred end after its captured lease expires", async () => { + vi.useFakeTimers(); + let cleanup: (() => Promise) | undefined; + try { + vi.setSystemTime(new Date("2026-07-10T12:00:00.000Z")); const { engine } = testEngine(); const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); dirs.push(workspaceRoot); + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {}, - projectBindingWorkspaceStore: new ProjectBindingWorkspaceStore({ root: workspaceRoot }), + projectBindingWorkspaceStore: workspaces, }); cleanup = async () => { await app.closeCapletsSessions(); @@ -1060,27 +2338,30 @@ describe("createHttpServeApp", () => { }, ); const created = (await session.json()) as { - binding: { bindingId: string }; - sessionId: string; + binding: { bindingId: string; expiresAt: string }; }; + workspaces.deferNextWrite(); + const ending = app.request( + `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/session`, + { method: "DELETE", headers: { "content-type": "application/json" }, body: "{}" }, + ); + await workspaces.nextWriteStarted; await vi.advanceTimersByTimeAsync(60_000); + workspaces.releaseNextWrite(); - const heartbeat = await app.request( - `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/heartbeat`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ sessionId: created.sessionId, state: "ready", syncState: "idle" }), - }, - ); - expect(heartbeat.status).toBe(404); - const status = await app.request( - `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/status`, - ); - await expect(status.json()).resolves.toMatchObject({ - bindingId: created.binding.bindingId, - state: "not_attached", - }); + expect((await ending).status).toBe(403); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest("development_unauthenticated", "sha256_repo"), + ), + ).resolves.toEqual([ + expect.objectContaining({ + bindingId: created.binding.bindingId, + state: "ended", + active: false, + expiresAt: created.binding.expiresAt, + }), + ]); await cleanup(); cleanup = undefined; @@ -1090,62 +2371,67 @@ describe("createHttpServeApp", () => { } }); - it("accepts Project Binding WebSocket connections for owned sessions", async () => { + it("does not acknowledge a socket end after post-write durable role loss", async () => { const { engine } = testEngine(); + const store = remoteCredentialStore(); const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); dirs.push(workspaceRoot); - const app = createHttpServeApp(httpOptions(), engine, { + const workspaces = new DeferredProjectBindingWorkspaceStore({ root: workspaceRoot }); + const app = createHttpServeApp(httpOptions({ auth: { type: "remote_credentials" } }), engine, { writeErr: () => {}, - projectBindingWorkspaceStore: new ProjectBindingWorkspaceStore({ root: workspaceRoot }), + remoteCredentialStore: store, + projectBindingWorkspaceStore: workspaces, }); const server = await withTimeout(startTestHttpServer(app), "start test HTTP server"); + const credentials = pairedClient(store, `${server.origin}/`); try { - const session = await withTimeout( - fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), - }), - "create Project Binding session", - ); - expect(session.status).toBe(201); + const session = await fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { + method: "POST", + headers: { + authorization: `Bearer ${credentials.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }); const created = (await session.json()) as { - binding: { bindingId: string; state: string; syncState: string }; + binding: { bindingId: string }; sessionId: string; }; const socket = new WebSocket( - `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}&projectFingerprint=sha256_repo`, + `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}`, + [ + "caplets.project-binding.v1", + `caplets.bearer.${Buffer.from(credentials.accessToken).toString("base64url")}`, + ], ); try { - await withTimeout(waitForSocketOpen(socket), "open Project Binding WebSocket"); - await expect( - withTimeout(nextSocketJson(socket), "receive Project Binding ready"), - ).resolves.toMatchObject({ - type: "ready", - bindingId: created.binding.bindingId, - sessionId: created.sessionId, - syncState: "pending", + await withTimeout(waitForSocketOpen(socket), "open end authorization socket"); + await withTimeout(nextSocketJson(socket), "receive Project Binding ready"); + const messages: unknown[] = []; + socket.on("message", (data) => { + messages.push(JSON.parse(data.toString()) as unknown); }); + workspaces.deferNextWrite(); + const closed = waitForSocketClose(socket); socket.send( JSON.stringify({ - type: "heartbeat", + type: "end", bindingId: created.binding.bindingId, sessionId: created.sessionId, - state: "ready", - syncState: "idle", - }), - ); - await withTimeout( - waitFor(async () => { - const status = await fetch( - `${server.origin}/v1/attach/project-bindings/${created.binding.bindingId}/status`, - ); - const payload = (await status.json()) as { state: string; syncState: string }; - expect(payload).toMatchObject({ state: "ready", syncState: "idle" }); + reason: { code: "completed", message: "Project Binding completed." }, }), - "observe Project Binding heartbeat", ); + await withTimeout(workspaces.nextWriteStarted, "start in-flight terminal lease write"); + store.changeClientRole(credentials.clientId, "operator"); + workspaces.releaseNextWrite(); + + await expect( + withTimeout(closed, "close post-write demoted end socket"), + ).resolves.toMatchObject({ + code: 1008, + }); + expect(messages).toEqual([]); } finally { socket.terminate(); } @@ -1155,101 +2441,67 @@ describe("createHttpServeApp", () => { } }); - it("ends Project Binding sessions when sockets close without an explicit end message", async () => { - const { engine } = testEngine(); - const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); - dirs.push(workspaceRoot); - const app = createHttpServeApp(httpOptions(), engine, { - writeErr: () => {}, - projectBindingWorkspaceStore: new ProjectBindingWorkspaceStore({ root: workspaceRoot }), - }); - const server = await withTimeout(startTestHttpServer(app), "start test HTTP server"); - - try { - const session = await withTimeout( - fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), - }), - "create Project Binding session", - ); - expect(session.status).toBe(201); - const created = (await session.json()) as { - binding: { bindingId: string }; - sessionId: string; - }; - const socket = new WebSocket( - `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}&projectFingerprint=sha256_repo`, - ); - await withTimeout(waitForSocketOpen(socket), "open Project Binding WebSocket"); - await withTimeout(nextSocketJson(socket), "receive Project Binding ready"); - socket.close(); - - await withTimeout( - waitFor(async () => { - const status = await fetch( - `${server.origin}/v1/attach/project-bindings/${created.binding.bindingId}/status`, - ); - await expect(status.json()).resolves.toMatchObject({ - bindingId: created.binding.bindingId, - state: "not_attached", - }); - }), - "observe Project Binding close cleanup", - ); - } finally { - await withTimeout(server.close(), "close test HTTP server"); - await engine.close(); - } - }); - - it("authenticates Project Binding WebSocket connections with bearer subprotocols", async () => { + it("terminalizes an active Project Binding socket after durable client revocation", async () => { const { engine } = testEngine(); const store = remoteCredentialStore(); const workspaceRoot = mkdtempSync(join(tmpdir(), "caplets-binding-workspaces-")); dirs.push(workspaceRoot); + const workspaces = new ProjectBindingWorkspaceStore({ root: workspaceRoot }); const app = createHttpServeApp(httpOptions({ auth: { type: "remote_credentials" } }), engine, { writeErr: () => {}, remoteCredentialStore: store, - projectBindingWorkspaceStore: new ProjectBindingWorkspaceStore({ root: workspaceRoot }), + projectBindingWorkspaceStore: workspaces, }); const server = await withTimeout(startTestHttpServer(app), "start test HTTP server"); const credentials = pairedClient(store, `${server.origin}/`); try { - const session = await withTimeout( - fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { - method: "POST", - headers: { - authorization: `Bearer ${credentials.accessToken}`, - "content-type": "application/json", - }, - body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), - }), - "create authenticated Project Binding session", - ); - expect(session.status).toBe(201); + const session = await fetch(`${server.origin}/v1/attach/project-bindings/sessions`, { + method: "POST", + headers: { + authorization: `Bearer ${credentials.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ projectRoot: "/repo", projectFingerprint: "sha256_repo" }), + }); const created = (await session.json()) as { binding: { bindingId: string }; sessionId: string; }; const socket = new WebSocket( - `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}&projectFingerprint=sha256_repo`, + `${server.origin.replace("http:", "ws:")}/v1/attach/project-bindings/connect?bindingId=${encodeURIComponent(created.binding.bindingId)}&sessionId=${encodeURIComponent(created.sessionId)}`, [ "caplets.project-binding.v1", `caplets.bearer.${Buffer.from(credentials.accessToken).toString("base64url")}`, ], ); try { - await withTimeout(waitForSocketOpen(socket), "open authenticated Project Binding socket"); + await withTimeout(waitForSocketOpen(socket), "open revocable Project Binding socket"); + await withTimeout(nextSocketJson(socket), "receive Project Binding ready"); + store.revokeClient(credentials.clientId); + const closed = waitForSocketClose(socket); + socket.send( + JSON.stringify({ + type: "heartbeat", + bindingId: created.binding.bindingId, + sessionId: created.sessionId, + state: "ready", + syncState: "idle", + }), + ); + await expect( - withTimeout(nextSocketJson(socket), "receive authenticated Project Binding ready"), + withTimeout(closed, "close revoked Project Binding socket"), ).resolves.toMatchObject({ - type: "ready", - bindingId: created.binding.bindingId, - sessionId: created.sessionId, + code: 1008, }); + await expect( + workspaces.listLeases( + projectBindingWorkspaceFingerprintForTest(credentials.clientId, "sha256_repo"), + ), + ).resolves.toEqual([ + expect.objectContaining({ bindingId: created.binding.bindingId, active: false }), + ]); } finally { socket.terminate(); } @@ -2934,6 +4186,13 @@ async function nextSocketJson(socket: WebSocket): Promise { }); } +async function waitForSocketClose(socket: WebSocket): Promise<{ code: number; reason: string }> { + const { promise, resolve, reject } = Promise.withResolvers<{ code: number; reason: string }>(); + socket.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); + socket.once("error", reject); + return await promise; +} + async function waitFor(assertion: () => Promise, attempts = 20): Promise { let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt += 1) { @@ -2948,7 +4207,7 @@ async function waitFor(assertion: () => Promise, attempts = 20): Promise(promise: Promise, label: string, ms = 1_000): Promise { +async function withTimeout(promise: Promise, label: string, ms = 10_000): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ @@ -3028,6 +4287,93 @@ function remoteCredentialStore(): RemoteServerCredentialStore { return new RemoteServerCredentialStore({ dir: tempDir("caplets-http-remote-credentials-") }); } +class DeferredProjectBindingWorkspaceStore extends ProjectBindingWorkspaceStore { + nextWriteStarted: Promise = Promise.resolve(); + writeLeases: ProjectBindingLease[] = []; + private afterNextWritePostCommit: (() => void) | undefined; + private failedWrites = 0; + private deferredWrite: + | { + started: PromiseWithResolvers; + release: PromiseWithResolvers; + } + | undefined; + + deferNextWrite(): void { + this.deferredWrite = { + started: Promise.withResolvers(), + release: Promise.withResolvers(), + }; + this.nextWriteStarted = this.deferredWrite.started.promise; + } + + releaseNextWrite(): void { + this.deferredWrite?.release.resolve(); + } + + failNextWrite(): void { + this.failedWrites += 1; + } + + runAfterNextWritePostCommit(action: () => void): void { + this.afterNextWritePostCommit = action; + } + + override async writeLease(lease: ProjectBindingLease): Promise { + const deferred = this.deferredWrite; + if (deferred) { + deferred.started.resolve(); + await deferred.release.promise; + if (this.deferredWrite === deferred) this.deferredWrite = undefined; + } + this.writeLeases.push(lease); + if (this.failedWrites > 0) { + this.failedWrites -= 1; + throw new Error("terminal lease write failed"); + } + await super.writeLease(lease); + const afterNextWritePostCommit = this.afterNextWritePostCommit; + this.afterNextWritePostCommit = undefined; + if (afterNextWritePostCommit) { + queueMicrotask(() => queueMicrotask(afterNextWritePostCommit)); + } + } +} + +class DeferredWorkspaceCleanupStore extends ProjectBindingWorkspaceStore { + cleanupCalls = 0; + readonly releaseCleanup = Promise.withResolvers(); + + override async cleanup() { + this.cleanupCalls += 1; + await this.releaseCleanup.promise; + return await super.cleanup(); + } +} + +class FailingProjectBindingWorkspaceStore extends ProjectBindingWorkspaceStore { + private failedWrites = 0; + + failNextWrite(): void { + this.failedWrites += 1; + } + + override async writeLease(lease: ProjectBindingLease): Promise { + if (this.failedWrites > 0) { + this.failedWrites -= 1; + throw new Error("terminal lease write failed"); + } + await super.writeLease(lease); + } +} + +function projectBindingWorkspaceFingerprintForTest( + ownerKey: string, + projectFingerprint: string, +): string { + return `sha256_${createHash("sha256").update(ownerKey).update("\0").update(projectFingerprint).digest("hex")}`; +} + function pairedClient( store: RemoteServerCredentialStore, hostUrl = "http://127.0.0.1:5387/", From df6879de7e24aed28eba9adb87cc086918fb7fea Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 10 Jul 2026 11:27:09 -0400 Subject: [PATCH 8/9] fix: address architecture review feedback Close linked MCP transports symmetrically, preserve per-target catalog failure activity, and make media and privacy boundaries explicit. Harden Vault reveal race coverage and simplify redundant assertions and casts. --- .../src/components/DashboardApp.test.tsx | 3 +++ .../dashboard/src/components/DashboardApp.tsx | 2 +- packages/core/src/caplet-sets.ts | 4 +-- .../src/current-host/catalog-operations.ts | 21 +++++++++------ packages/core/src/google-discovery/manager.ts | 10 +++---- packages/core/src/http/response.ts | 17 +++++------- .../test/current-host-administration.test.ts | 27 +++++++++++++++++++ packages/core/test/mcp-test-client.ts | 10 +++++++ packages/core/test/runtime.test.ts | 2 -- packages/web-observability/src/posthog.ts | 2 +- packages/web-observability/src/privacy.ts | 8 ++---- 11 files changed, 68 insertions(+), 38 deletions(-) diff --git a/apps/dashboard/src/components/DashboardApp.test.tsx b/apps/dashboard/src/components/DashboardApp.test.tsx index bbf1638c..b5bb81c5 100644 --- a/apps/dashboard/src/components/DashboardApp.test.tsx +++ b/apps/dashboard/src/components/DashboardApp.test.tsx @@ -234,5 +234,8 @@ describe("Vault reveal races", () => { current.resolve({ value: "current secret" }); }); await flush(); + expect(container?.textContent).toContain("current secret"); + expect(dashboardApi).toHaveBeenCalledWith("vault"); + expect(toast.success).toHaveBeenCalledWith("Vault value revealed"); }); }); diff --git a/apps/dashboard/src/components/DashboardApp.tsx b/apps/dashboard/src/components/DashboardApp.tsx index b7ac699a..bcf310f6 100644 --- a/apps/dashboard/src/components/DashboardApp.tsx +++ b/apps/dashboard/src/components/DashboardApp.tsx @@ -2381,7 +2381,7 @@ function VaultPage({ const values = data.vault?.values ?? []; const isMounted = useRef(true); const revealGeneration = useRef(0); - const expiry = useMemo(() => createEphemeralRevealExpiry(() => setRevealed(undefined)), []); + const [expiry] = useState(() => createEphemeralRevealExpiry(() => setRevealed(undefined))); function clearRevealed() { revealGeneration.current += 1; expiry.cancel(); diff --git a/packages/core/src/caplet-sets.ts b/packages/core/src/caplet-sets.ts index ed2b55f8..55ef81ea 100644 --- a/packages/core/src/caplet-sets.ts +++ b/packages/core/src/caplet-sets.ts @@ -1,4 +1,4 @@ -import type { CompatibilityCallToolResult, Tool } from "@modelcontextprotocol/sdk/types"; +import type { Tool } from "@modelcontextprotocol/sdk/types"; import { resolve } from "node:path"; import { createBackendOperationRuntime, @@ -151,7 +151,7 @@ export class CapletSetManager { try { return await handleServerTool(caplet, args, child.registry, child.runtime); } catch (error) { - return errorResult(error) as CompatibilityCallToolResult; + return errorResult(error) as BackendCallToolResult; } } diff --git a/packages/core/src/current-host/catalog-operations.ts b/packages/core/src/current-host/catalog-operations.ts index b9ed5890..b24ed304 100644 --- a/packages/core/src/current-host/catalog-operations.ts +++ b/packages/core/src/current-host/catalog-operations.ts @@ -118,10 +118,7 @@ async function catalogInstallOutcome( ); return { kind: "catalog_install", installed, setupActions }; } catch (error) { - appendFailureActivity(dependencies, principal, "catalog_installed", { - type: "catalog", - id: capletIds?.[0] ?? "current-host", - }); + appendCatalogFailureActivities(dependencies, principal, "catalog_installed", capletIds); throw error; } } @@ -156,14 +153,22 @@ async function catalogUpdateOutcome( } return { kind: "catalog_update", installed, setupActions: [] }; } catch (error) { - appendFailureActivity(dependencies, principal, "catalog_updated", { - type: "catalog", - id: capletIds?.[0] ?? "current-host", - }); + appendCatalogFailureActivities(dependencies, principal, "catalog_updated", capletIds); throw error; } } +function appendCatalogFailureActivities( + dependencies: CurrentHostOperationsDependencies, + principal: CurrentHostOperatorPrincipal, + action: "catalog_installed" | "catalog_updated", + capletIds: string[] | undefined, +): void { + for (const id of capletIds && capletIds.length > 0 ? capletIds : ["current-host"]) { + appendFailureActivity(dependencies, principal, action, { type: "catalog", id }); + } +} + function globalCatalogTarget(control: CurrentHostControlContext): { destinationRoot: string; lockfilePath: string; diff --git a/packages/core/src/google-discovery/manager.ts b/packages/core/src/google-discovery/manager.ts index 5a9db821..ccde3da7 100644 --- a/packages/core/src/google-discovery/manager.ts +++ b/packages/core/src/google-discovery/manager.ts @@ -11,7 +11,7 @@ import { } from "../downstream"; import { CapletsError, toSafeError } from "../errors"; import { readHttpLikeResponse } from "../http/response"; -import { isAbortError, readLimitedText } from "../http/utils"; +import { DEFAULT_MAX_RESPONSE_BYTES, isAbortError, readLimitedText } from "../http/utils"; import { readMediaInput, type ResolvedMediaInput } from "../media"; import type { ServerRegistry } from "../registry"; import { markdownStructuredContent } from "../result-content"; @@ -139,9 +139,7 @@ export class GoogleDiscoveryManager { method: operation.method, ...(this.options.artifactDir ? { artifactDir: this.options.artifactDir } : {}), ...(this.options.exposeLocalArtifactPaths === false ? { exposeLocalPath: false } : {}), - ...(this.options.mediaInlineThresholdBytes === undefined - ? {} - : { maxInlineBytes: this.options.mediaInlineThresholdBytes }), + maxInlineBytes: this.options.mediaInlineThresholdBytes ?? DEFAULT_MAX_RESPONSE_BYTES, ...(typeof args.filename === "string" ? { filename: args.filename } : {}), ...(typeof args.outputPath === "string" ? { outputPath: args.outputPath } : {}), maxBytes: DEFAULT_MEDIA_RESPONSE_MAX_BYTES, @@ -212,9 +210,7 @@ export class GoogleDiscoveryManager { method: operation.method, ...(this.options.artifactDir ? { artifactDir: this.options.artifactDir } : {}), ...(this.options.exposeLocalArtifactPaths === false ? { exposeLocalPath: false } : {}), - ...(this.options.mediaInlineThresholdBytes === undefined - ? {} - : { maxInlineBytes: this.options.mediaInlineThresholdBytes }), + maxInlineBytes: this.options.mediaInlineThresholdBytes ?? DEFAULT_MAX_RESPONSE_BYTES, }); return { content: markdownStructuredContent(parsed, { diff --git a/packages/core/src/http/response.ts b/packages/core/src/http/response.ts index 7ba8d833..f99f4474 100644 --- a/packages/core/src/http/response.ts +++ b/packages/core/src/http/response.ts @@ -59,12 +59,7 @@ async function readResponseBody( const chunks: Uint8Array[] = []; let byteLength = 0; let writer: MediaArtifactWriter | undefined; - const inspectChunk = - settings.mimeType === "application/json" || - settings.mimeType.endsWith("+json") || - settings.mimeType.endsWith("/json") - ? options.inspectChunk - : undefined; + const inspectChunk = isJsonMime(settings.mimeType) ? options.inspectChunk : undefined; try { if (!settings.allowInline) { writer = await createResponseArtifactWriter(response, options, settings.mimeType); @@ -185,12 +180,12 @@ function shouldInline(response: Response, mimeType: string): boolean { if (isAttachment(response)) { return false; } + return mimeType === "" || isJsonMime(mimeType) || mimeType.startsWith("text/"); +} + +function isJsonMime(mimeType: string): boolean { return ( - mimeType === "" || - mimeType === "application/json" || - mimeType.endsWith("+json") || - mimeType.endsWith("/json") || - mimeType.startsWith("text/") + mimeType === "application/json" || mimeType.endsWith("+json") || mimeType.endsWith("/json") ); } diff --git a/packages/core/test/current-host-administration.test.ts b/packages/core/test/current-host-administration.test.ts index 3aa517b5..c58d7503 100644 --- a/packages/core/test/current-host-administration.test.ts +++ b/packages/core/test/current-host-administration.test.ts @@ -157,6 +157,33 @@ describe("Current Host administration operations", () => { await setup.engine.close(); } }); + + it("records every requested catalog target when a batch update fails", async () => { + const setup = testOperations(); + try { + await expect( + setup.operations.execute(setup.principal, { + kind: "catalog_update", + capletIds: ["alpha", "beta"], + disableCatalogIndexing: true, + }), + ).rejects.toBeDefined(); + const targets = setup.activity + .list() + .entries.filter((entry) => entry.action === "catalog_updated") + .map((entry) => entry.target); + expect(targets).toHaveLength(2); + expect(targets).toEqual( + expect.arrayContaining([ + { type: "catalog", id: "alpha" }, + { type: "catalog", id: "beta" }, + ]), + ); + } finally { + await setup.engine.close(); + } + }); + it("redacts credential assignments and filesystem paths from classified failures", () => { const safe = toCurrentHostSafeError( new CapletsError( diff --git a/packages/core/test/mcp-test-client.ts b/packages/core/test/mcp-test-client.ts index 2164b201..4b4f4a3c 100644 --- a/packages/core/test/mcp-test-client.ts +++ b/packages/core/test/mcp-test-client.ts @@ -22,6 +22,7 @@ class LinkedTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; onmessage?: NonNullable; + private closed = false; async start(): Promise {} @@ -37,6 +38,15 @@ class LinkedTransport implements Transport { } async close(): Promise { + if (this.closed) return; + this.closed = true; + const peer = this.peer; + delete this.peer; + if (peer && !peer.closed) { + peer.closed = true; + delete peer.peer; + peer.onclose?.(); + } this.onclose?.(); } } diff --git a/packages/core/test/runtime.test.ts b/packages/core/test/runtime.test.ts index 61a815fe..19b1626e 100644 --- a/packages/core/test/runtime.test.ts +++ b/packages/core/test/runtime.test.ts @@ -417,8 +417,6 @@ describe("CapletsRuntime", () => { await expect(runtime.reload()).resolves.toBe(true); - expect(server).not.toHaveProperty("registerResource"); - expect(server).not.toHaveProperty("registerPrompt"); expect(runtime.registeredToolIds()).toEqual([]); expect(server.registered).toEqual(new Map()); expect(errors.join("")).toContain("Caplets exposure refresh failed"); diff --git a/packages/web-observability/src/posthog.ts b/packages/web-observability/src/posthog.ts index 69056565..46310301 100644 --- a/packages/web-observability/src/posthog.ts +++ b/packages/web-observability/src/posthog.ts @@ -97,7 +97,7 @@ function categoricalProperties(input: Record): WebEventProperty for (const [key, value] of Object.entries(input)) { if (typeof value !== "string") continue; try { - assertWebEventSafeProperties({ [key]: value } as never); + assertWebEventSafeProperties({ [key]: value }); properties[key] = value; } catch { // Final provider payloads silently drop SDK and application additions. diff --git a/packages/web-observability/src/privacy.ts b/packages/web-observability/src/privacy.ts index d7480e78..57b8fdc5 100644 --- a/packages/web-observability/src/privacy.ts +++ b/packages/web-observability/src/privacy.ts @@ -1,5 +1,3 @@ -import type { WebEventPropertySet } from "./events"; - const ALLOWED_WEB_KEYS = new Set([ "surface", "route_family", @@ -56,9 +54,7 @@ const RAW_VALUE_PATTERNS = [ /^gh[pousr]_[a-z0-9]/iu, ]; -export function assertWebEventSafeProperties( - properties: WebEventPropertySet & Record, -): void { +export function assertWebEventSafeProperties(properties: Record): void { for (const [key, value] of Object.entries(properties)) { if (!ALLOWED_WEB_KEYS.has(key)) { throw new Error(`unknown web telemetry property: ${key}`); @@ -186,7 +182,7 @@ function filterSentryTags(tags: Record): Record for (const [key, value] of Object.entries(tags)) { if (!ALLOWED_WEB_KEYS.has(key) || typeof value !== "string") continue; try { - assertWebEventSafeProperties({ [key]: value } as never); + assertWebEventSafeProperties({ [key]: value }); filtered[key] = value; } catch { // Drop unsafe SDK or caller-provided tags. From 6383454bd209c27ee13791ad643e4545ea7f759a Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 10 Jul 2026 20:16:12 -0400 Subject: [PATCH 9/9] fix(core): harden artifact and reveal recovery --- .../src/components/DashboardApp.test.tsx | 31 ++++- .../dashboard/src/components/DashboardApp.tsx | 24 ++-- packages/core/src/media/artifacts.ts | 108 +++++++++++++++++- packages/core/test/media-artifacts.test.ts | 31 +++++ 4 files changed, 180 insertions(+), 14 deletions(-) diff --git a/apps/dashboard/src/components/DashboardApp.test.tsx b/apps/dashboard/src/components/DashboardApp.test.tsx index b5bb81c5..259f4a62 100644 --- a/apps/dashboard/src/components/DashboardApp.test.tsx +++ b/apps/dashboard/src/components/DashboardApp.test.tsx @@ -26,6 +26,7 @@ import { DashboardApp } from "./DashboardApp"; type Deferred = { promise: Promise; + reject: (error: unknown) => void; resolve: (value: T) => void; }; @@ -41,11 +42,13 @@ let container: HTMLDivElement | undefined; let revealResponses: Array | { value: string }>; function deferred(): Deferred { + let reject!: (error: unknown) => void; let resolve!: (value: T) => void; - const promise = new Promise((fulfill) => { + const promise = new Promise((fulfill, fail) => { + reject = fail; resolve = fulfill; }); - return { promise, resolve }; + return { promise, reject, resolve }; } function responseFor(path: string) { @@ -202,6 +205,30 @@ describe("Vault reveal races", () => { expect(toast.success).not.toHaveBeenCalled(); }); + it("does not show an error when Hide invalidates a rejected reveal", async () => { + await mountVault(); + await reveal("visible secret"); + toast.error.mockClear(); + + const stale = deferred<{ value: string }>(); + revealResponses.push(stale); + await openRevealConfirmation(); + await confirmReveal(); + await waitFor(() => + dashboardApi.mock.calls.some(([path]) => path === "vault/reveal") ? true : undefined, + ); + + await act(async () => { + button("Hide revealed value").click(); + }); + await act(async () => { + stale.reject(new Error("stale request failed")); + }); + await flush(); + + expect(toast.error).not.toHaveBeenCalled(); + }); + it("does not refresh or toast when a newer reveal invalidates an older response", async () => { await mountVault(); await reveal("visible secret"); diff --git a/apps/dashboard/src/components/DashboardApp.tsx b/apps/dashboard/src/components/DashboardApp.tsx index bcf310f6..629621db 100644 --- a/apps/dashboard/src/components/DashboardApp.tsx +++ b/apps/dashboard/src/components/DashboardApp.tsx @@ -2525,13 +2525,23 @@ function VaultPage({ if (!isMounted.current || confirmation !== `reveal ${rawKey}`) return; const requestGeneration = ++revealGeneration.current; await action("Vault value revealed", async () => { - const revealed = await dashboardApi<{ value: string }>("vault/reveal", { - method: "POST", - body: JSON.stringify({ - key: entry.key, - confirmation, - }), - }); + let revealed: { value: string }; + try { + revealed = await dashboardApi<{ value: string }>("vault/reveal", { + method: "POST", + body: JSON.stringify({ + key: entry.key, + confirmation, + }), + }); + } catch (error) { + if ( + !isMounted.current || + requestGeneration !== revealGeneration.current + ) + return ACTION_DISCARDED; + throw error; + } if ( !isMounted.current || requestGeneration !== revealGeneration.current diff --git a/packages/core/src/media/artifacts.ts b/packages/core/src/media/artifacts.ts index 4b66f8f1..2e852d86 100644 --- a/packages/core/src/media/artifacts.ts +++ b/packages/core/src/media/artifacts.ts @@ -1,7 +1,18 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, lstatSync, readFileSync, statSync } from "node:fs"; -import { chmod, mkdir, open, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + open, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { DEFAULT_ARTIFACT_DIR } from "../config/paths"; import { CapletsError } from "../errors"; @@ -44,6 +55,8 @@ type ParsedArtifactUri = { }; const artifactPublicationLocks = new Map>(); +const PUBLICATION_LOCK_WAIT_MS = 30_000; +const OWNERLESS_LOCK_STALE_MS = 1_000; async function serializeArtifactPublication( target: string, @@ -58,7 +71,7 @@ async function serializeArtifactPublication( artifactPublicationLocks.set(target, queued); await previous.catch(() => {}); try { - return await operation(); + return await withArtifactPublicationFileLock(target, operation); } finally { release?.(); if (artifactPublicationLocks.get(target) === queued) { @@ -67,6 +80,70 @@ async function serializeArtifactPublication( } } +async function withArtifactPublicationFileLock( + target: string, + operation: () => Promise, +): Promise { + const lockDir = resolve(dirname(target), `.${basename(target)}.publication.lock`); + const ownerPath = resolve(lockDir, "owner.json"); + const startedAt = Date.now(); + for (;;) { + try { + await mkdir(lockDir, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + if (await publicationLockIsStale(lockDir, ownerPath)) { + await rm(lockDir, { force: true, recursive: true }); + continue; + } + if (Date.now() - startedAt >= PUBLICATION_LOCK_WAIT_MS) { + throw new CapletsError( + "DOWNSTREAM_TOOL_ERROR", + `Timed out waiting to publish media artifact ${target}`, + ); + } + await delay(25); + continue; + } + try { + await writeFile(ownerPath, JSON.stringify({ pid: process.pid }), { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + return await operation(); + } finally { + await rm(lockDir, { force: true, recursive: true }); + } + } +} + +async function publicationLockIsStale(lockDir: string, ownerPath: string): Promise { + try { + const owner = JSON.parse(await readFile(ownerPath, "utf8")) as { pid?: unknown }; + if (!Number.isInteger(owner.pid)) { + return true; + } + try { + process.kill(owner.pid as number, 0); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + return true; + } + try { + return Date.now() - (await stat(lockDir)).mtimeMs >= OWNERLESS_LOCK_STALE_MS; + } catch (statError) { + return (statError as NodeJS.ErrnoException).code === "ENOENT"; + } + } +} + async function removeArtifactBackups(paths: string[], errorMessage: string): Promise { let pending = paths; for (let attempt = 0; attempt < 2 && pending.length > 0; attempt += 1) { @@ -82,7 +159,7 @@ async function removeArtifactBackups(paths: string[], errorMessage: string): Pro } } -async function scavengeArtifactBackups(target: string): Promise { +async function recoverOrScavengeArtifactBackups(target: string): Promise { let entries: string[]; try { entries = await readdir(dirname(target)); @@ -93,6 +170,26 @@ async function scavengeArtifactBackups(target: string): Promise { throw error; } const prefix = `.${basename(target)}.`; + const backupPaths = entries + .filter((entry) => entry.startsWith(prefix) && entry.endsWith(".previous")) + .map((entry) => resolve(dirname(target), entry)) + .filter((path) => lstatSync(path).isFile()); + let recoveredPaths: string[] = []; + if (!existsSync(target) && backupPaths.length > 0) { + const candidates = await Promise.all( + backupPaths.map(async (path) => ({ path, modifiedAt: (await stat(path)).mtimeMs })), + ); + candidates.sort((left, right) => right.modifiedAt - left.modifiedAt); + const recoveryPath = candidates[0]!.path; + const recoveryMetadataPath = artifactMetadataPath(recoveryPath); + await rename(recoveryPath, target); + recoveredPaths = [recoveryPath]; + if (existsSync(recoveryMetadataPath) && lstatSync(recoveryMetadataPath).isFile()) { + await rm(artifactMetadataPath(target), { force: true }); + await rename(recoveryMetadataPath, artifactMetadataPath(target)); + recoveredPaths.push(recoveryMetadataPath); + } + } await removeArtifactBackups( entries .filter( @@ -100,7 +197,8 @@ async function scavengeArtifactBackups(target: string): Promise { entry.startsWith(prefix) && (entry.endsWith(".previous") || entry.endsWith(".previous.caplets.json")), ) - .map((entry) => resolve(dirname(target), entry)), + .map((entry) => resolve(dirname(target), entry)) + .filter((path) => !recoveredPaths.includes(path)), "Could not remove stale media artifact publication backups", ); } @@ -226,7 +324,7 @@ export async function createMediaArtifactWriter( const digest = hash.digest("hex"); return await serializeArtifactPublication(target, async () => { try { - await scavengeArtifactBackups(target); + await recoverOrScavengeArtifactBackups(target); await chmod(tempPath, 0o600); await writeArtifactMetadata(tempPath, input.mimeType ? { mimeType: input.mimeType } : {}); rejectSymlinkPathComponents(rootDir, target, true); diff --git a/packages/core/test/media-artifacts.test.ts b/packages/core/test/media-artifacts.test.ts index 20ce16b1..7a8cd085 100644 --- a/packages/core/test/media-artifacts.test.ts +++ b/packages/core/test/media-artifacts.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, rmSync, symlinkSync, truncateSync, @@ -13,6 +14,7 @@ import Ajv from "ajv"; import { afterEach, describe, expect, it, vi } from "vitest"; import { artifactUri, + createMediaArtifactWriter, readMediaInput, resolveMediaArtifact, writeMediaArtifact, @@ -236,6 +238,35 @@ describe("media artifacts", () => { expect(result.path).not.toBe(outputPath); }); + it("restores an orphaned publication backup before a failed retry", async () => { + const root = tempDir("caplets-artifacts-"); + const outputDir = join(root, "drive", "call-1"); + const outputPath = join(outputDir, "report.pdf"); + const backupPath = join(outputDir, ".report.pdf.crashed.previous"); + mkdirSync(outputDir, { recursive: true }); + writeFileSync(backupPath, "previous-pdf"); + writeFileSync(`${backupPath}.caplets.json`, JSON.stringify({ mimeType: "application/pdf" })); + + const writer = await createMediaArtifactWriter({ + rootDir: root, + capletId: "drive", + outputPath, + mimeType: "application/pdf", + }); + await writer.write(Buffer.from("replacement-pdf")); + const partialPath = join( + outputDir, + readdirSync(outputDir).find((entry) => entry.endsWith(".partial"))!, + ); + rmSync(partialPath); + + await expect(writer.complete()).rejects.toMatchObject({ code: "ENOENT" }); + expect(readFileSync(outputPath, "utf8")).toBe("previous-pdf"); + expect(JSON.parse(readFileSync(`${outputPath}.caplets.json`, "utf8"))).toEqual({ + mimeType: "application/pdf", + }); + }); + it("cancels oversized responses rejected by content-length before reading", async () => { let cancelled = false; const body = new ReadableStream({