From 5d8893fd32a8a3b7a81769daf8bf92199fc132c1 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 23 Jun 2026 19:44:22 -0400 Subject: [PATCH 1/3] feat(core): add stacked remote runtime --- .changeset/stacked-remote-runtime.md | 6 + CONCEPTS.md | 6 + README.md | 10 + .../src/content/docs/agent-integrations.mdx | 9 + apps/docs/src/content/docs/remote-attach.mdx | 22 +- .../docs/src/content/docs/troubleshooting.mdx | 13 + ...-23-stacked-remote-runtime-requirements.md | 157 ++++ ...-23-codex-mcp-env-mitigation-ideation.html | 768 ++++++++++++++++++ docs/native-integrations.md | 6 +- ...23-002-feat-stacked-remote-runtime-plan.md | 188 +++++ docs/project-binding.md | 2 + packages/core/src/attach/api.ts | 87 ++ packages/core/src/attach/server.ts | 1 + packages/core/src/cli.ts | 53 +- packages/core/src/native/remote.ts | 185 ++++- packages/core/src/native/service.ts | 237 +++++- packages/core/src/serve/http.ts | 288 ++++++- packages/core/src/serve/index.ts | 67 +- packages/core/src/serve/options.ts | 85 +- packages/core/test/attach-api.test.ts | 104 +++ packages/core/test/attach-cli.test.ts | 26 +- packages/core/test/cli.test.ts | 25 + packages/core/test/native-remote.test.ts | 369 +++++++++ packages/core/test/serve-http.test.ts | 368 ++++++++- packages/core/test/serve-options.test.ts | 56 ++ 25 files changed, 3029 insertions(+), 109 deletions(-) create mode 100644 .changeset/stacked-remote-runtime.md create mode 100644 docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md create mode 100644 docs/ideation/2026-06-23-codex-mcp-env-mitigation-ideation.html create mode 100644 docs/plans/2026-06-23-002-feat-stacked-remote-runtime-plan.md diff --git a/.changeset/stacked-remote-runtime.md b/.changeset/stacked-remote-runtime.md new file mode 100644 index 00000000..a15733d4 --- /dev/null +++ b/.changeset/stacked-remote-runtime.md @@ -0,0 +1,6 @@ +--- +"@caplets/core": minor +"caplets": minor +--- + +Add stacked remote runtimes with `caplets serve --transport http --upstream-url `, make `caplets attach ` stdio-only, and carry per-session project context through attach/native remote sessions. diff --git a/CONCEPTS.md b/CONCEPTS.md index d3607cd3..1a43fbf5 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -86,6 +86,12 @@ The process where a local agent-facing Caplets runtime connects to a trusted Cap Remote Attach uses Remote Profiles for trust and credentials. Long-lived attach traffic treats credentials as refreshable runtime state rather than fixed startup state. +### Stacked Remote Runtime + +A local HTTP Caplets runtime that serves local Caplets while composing an upstream Caplets host through a configured upstream URL. + +Stacked Remote Runtime keeps project context session-scoped. `caplets attach` supplies the project root for a client session, while the long-running runtime owns env, Remote Profile, Project Binding, health, and composition behavior. + ### Remote Login The provider-neutral flow that trusts a local Caplets client to a Caplets host, whether the host is self-hosted or Caplets Cloud. diff --git a/README.md b/README.md index ef78d33f..17b166ca 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,16 @@ args = ["attach", "https://caplets.example.com/caplets"] } ``` +`caplets attach ` is always the stdio client command for MCP configs. To run a +long-lived local HTTP runtime that composes local and project Caplets with an upstream +host, start the runtime separately: + +```sh +caplets serve --transport http --upstream-url https://caplets.example.com/caplets +``` + +Then point agents at that local runtime with `caplets attach `. + Native integrations expose `caplets__code_mode` for multi-step TypeScript workflows over generated `caplets.` handles. Progressive exposure adds `caplets__` tools; direct exposure adds operation-level tools such as `caplets____`. diff --git a/apps/docs/src/content/docs/agent-integrations.mdx b/apps/docs/src/content/docs/agent-integrations.mdx index c2f2e345..c081ae85 100644 --- a/apps/docs/src/content/docs/agent-integrations.mdx +++ b/apps/docs/src/content/docs/agent-integrations.mdx @@ -74,3 +74,12 @@ Use: ``` For remote-backed MCP, use `caplets attach` instead of `caplets serve`. + +`caplets attach ` is stdio-only. If you want one local HTTP runtime to own environment, +Vault, Remote Login, reloads, and logs while still composing an upstream host, run: + +```sh +caplets serve --transport http --upstream-url https://caplets.example.com/caplets +``` + +Then configure the MCP client with `caplets attach `. diff --git a/apps/docs/src/content/docs/remote-attach.mdx b/apps/docs/src/content/docs/remote-attach.mdx index 85671c08..acd99cdf 100644 --- a/apps/docs/src/content/docs/remote-attach.mdx +++ b/apps/docs/src/content/docs/remote-attach.mdx @@ -4,7 +4,12 @@ description: Connect an agent to a remote Caplets runtime. --- Use `caplets attach` when the agent should connect to a remote or Cloud-backed Caplets -runtime instead of starting local backends. +runtime instead of starting local backends. `caplets attach ` is a stdio MCP adapter; +it does not run an HTTP server. + +Use `caplets serve --transport http --upstream-url ` for a long-running stacked +runtime that composes local user/global Caplets, per-session project Caplets, and an +upstream Caplets host. ## Modes @@ -68,6 +73,16 @@ After approval, configure the agent to launch attach: caplets attach https://caplets.example.com/caplets ``` +For a stacked local runtime, run the HTTP service outside the agent process: + +```sh +caplets serve --transport http --upstream-url https://caplets.example.com/caplets +``` + +Then configure the agent with `caplets attach `. The attach adapter +sends its current working directory as the session project root, so project Caplets are +resolved per attached client instead of from the long-running server's startup directory. + For Caplets Cloud native integrations, log in once and then set the Cloud URL selector: ```sh @@ -97,6 +112,11 @@ check. remote capability needs project-local context while the agent still talks to one Caplets MCP server. +Stacked runtimes also attempt upstream Project Binding automatically when an attach or +native session supplies a project root and the upstream supports Project Binding. If the +upstream binding path is unavailable, local project Caplets and non-project upstream +Caplets remain available with a diagnostic. + Attach connects to the runtime attach API at `/v1/attach`. Ordinary MCP clients that are not using attach continue to connect to `/v1/mcp`, which still honors the configured exposure mode for each Caplet. diff --git a/apps/docs/src/content/docs/troubleshooting.mdx b/apps/docs/src/content/docs/troubleshooting.mdx index db9aea3d..a200a3a2 100644 --- a/apps/docs/src/content/docs/troubleshooting.mdx +++ b/apps/docs/src/content/docs/troubleshooting.mdx @@ -168,6 +168,15 @@ caplets attach --once `caplets attach --once` validates the configured Project Binding WebSocket URL once and exits. +If an old config uses `caplets attach --transport http` or attach HTTP bind flags, replace +that server command with: + +```sh +caplets serve --transport http --upstream-url https://caplets.example.com/caplets +``` + +Keep MCP client configs on `caplets attach `. + ### Attach URL or workspace is wrong Expected symptom: the agent connects to a stale workspace, or the runtime reports that the @@ -201,6 +210,10 @@ Run the agent from the project root, then retry the smallest Code Mode script th project Caplet. If the capability needs setup commands, run the commands listed in the Caplet's `setup` metadata before asking the agent to call it. +For stacked runtimes, project-local Caplets come from the attach or native session's project +root. The long-running HTTP server startup directory is not used as the active project for +every client. + ## OSV no-auth smoke test If you need a public capability to separate Caplets setup from credential problems, install diff --git a/docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md b/docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md new file mode 100644 index 00000000..20dd5121 --- /dev/null +++ b/docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md @@ -0,0 +1,157 @@ +--- +date: 2026-06-23 +topic: stacked-remote-runtime +--- + +# Stacked Remote Runtime Requirements + +## Summary + +Add a stacked HTTP runtime shape where `caplets serve --transport http --upstream-url ` serves local Caplets together with an upstream Caplets host, while `caplets attach ` becomes a stdio-only session client that supplies project context to the runtime it connects to. + +--- + +## Problem Frame + +Codex MCP server startup does not reliably inherit the user's shell environment or pass expected environment variables through to server commands. That makes env-interpolated Caplets config fragile when Codex launches `caplets serve` or `caplets attach` directly. + +Caplets already has pieces that point away from env-dependent child startup: Remote Profiles keep remote credentials out of agent config, the Caplets Daemon models runtime launch as an install-time service contract, Remote Attach composes local and remote capabilities, Project Binding connects a local project root to a remote runtime, and Namespace Shadowing keeps local/upstream ID collisions predictable. + +The desired product shape is to move durable runtime responsibility into a local HTTP service that owns env, Vault, auth, logs, reloads, and health. Agent-facing `attach` should be a thin stdio session adapter rather than another long-lived server command with its own HTTP transport. + +--- + +## Key Decisions + +- **Stacked runtime is a `serve` role.** `caplets serve --transport http --upstream-url ` starts the composed runtime because it publishes an HTTP service for clients. +- **Remote session adapter is an `attach` role.** `caplets attach ` stays the command installed into MCP clients and always speaks stdio to the launching client. +- **Use `--upstream-url`, not `--remote-url`, on `serve`.** The flag names the server behind the local runtime rather than implying this process itself is the remote target. +- **Project context is session-scoped.** The long-running HTTP runtime must not treat its startup CWD as the active project for every client. +- **`caplets attach` uses CWD for project context.** Native integrations may pass an explicit project root through their API; the CLI attach adapter should default to its launch CWD. +- **Existing shadowing semantics stay authoritative.** Upstream Caplet `shadowing` policy decides collision behavior: `forbid` suppresses local same-ID Caplets, `allow` permits local bare-ID use, and `namespace` qualifies both sides. +- **Upstream Project Binding is automatic.** When a session supplies a project root and the upstream supports Project Binding, the stacked runtime should attempt binding without another flag. + +--- + +## Actors + +- A1. **Agent user:** Wants Codex and native integrations to see stable Caplets without hand-wiring env secrets into MCP config. +- A2. **MCP client:** Launches `caplets attach ` and communicates over stdio. +- A3. **Local stacked runtime:** Runs `caplets serve --transport http --upstream-url ` and composes local, project, and upstream Caplets. +- A4. **Upstream Caplets host:** Publishes remote capabilities, shadowing policy, Remote Profile requirements, and Project Binding support. +- A5. **Native integration:** Starts a Caplets session with an explicit project root when its host API exposes one. + +--- + +## Requirements + +**Command model** + +- R1. `caplets serve --transport http --upstream-url ` must start an HTTP runtime that composes local Caplets with the trusted upstream runtime at ``. +- R2. `caplets attach ` must expose stdio only to the launching MCP client and must not support HTTP serving options as the public attach contract. +- R3. `caplets attach ` must use its launch CWD as the session project root unless an explicit project root override is provided for tests or native integration plumbing. +- R4. Native integrations must be able to supply project root explicitly and fall back to CWD only when no host-provided root exists. + +**Composition and shadowing** + +- R5. The stacked runtime must expose a resolved surface containing local user/global Caplets, session project Caplets, and upstream Caplets. +- R6. The stacked runtime must apply existing upstream-authored shadowing semantics for every local/upstream base-ID collision. +- R7. Namespace-qualified IDs must use durable source identity and configured namespace aliases consistently across local and upstream sources. +- R8. Stacked composition must fail closed with diagnostics when a namespace collision cannot be resolved safely. + +**Project Binding** + +- R9. When a session has a project root and the upstream advertises Project Binding support, the stacked runtime must attempt upstream Project Binding automatically. +- R10. If upstream Project Binding fails, local project Caplets and non-project upstream Caplets should remain available when safe, with a degraded diagnostic that points to recovery. +- R11. Project context must be per session so one long-running local runtime can serve clients from different repositories without project leakage. + +**Credentials and env ownership** + +- R12. Agent configs must continue to contain stable non-secret selectors such as `caplets attach `, not bearer tokens, passwords, or copied env secrets. +- R13. The stacked runtime must resolve upstream credentials from Remote Profiles and local config secrets from Caplets-owned runtime mechanisms. +- R14. Upstream credentials, remote Vault values, and local env-derived config must not be mirrored into agent-visible config or session metadata. + +**Diagnostics and compatibility** + +- R15. Attach help and errors must guide users from removed HTTP attach usage toward `caplets serve --transport http --upstream-url `. +- R16. Serve help, docs, and generated setup guidance must teach `--upstream-url` for stacked runtimes. +- R17. `caplets doctor` should distinguish local runtime health, upstream Remote Profile health, and upstream Project Binding health. + +--- + +## Key Flows + +- F1. **Start stacked runtime** + - **Trigger:** User or daemon starts `caplets serve --transport http --upstream-url `. + - **Actors:** A1, A3, A4 + - **Steps:** The runtime resolves local config, resolves the upstream through Remote Profiles, starts HTTP service endpoints, and prepares to compose per-session project context. + - **Outcome:** Clients can attach to one local HTTP runtime that represents local plus upstream capabilities. + +- F2. **Attach from a project** + - **Trigger:** MCP client launches `caplets attach ` from a repository root. + - **Actors:** A2, A3 + - **Steps:** The attach adapter sends stdio to the client, supplies its CWD as session project root, and consumes the resolved runtime surface from the local runtime. + - **Outcome:** The client sees the composed Caplets surface for that repository without Codex launching env-sensitive server commands. + +- F3. **Compose a collision** + - **Trigger:** Local or project Caplet ID matches an upstream Caplet ID. + - **Actors:** A3, A4 + - **Steps:** The runtime reads the upstream Caplet shadowing policy and applies `forbid`, `allow`, or `namespace` behavior. + - **Outcome:** The visible Caplet IDs match current shadowing rules and never silently choose an ambiguous namespace collision. + +- F4. **Bind upstream project context** + - **Trigger:** A session supplies project root and upstream Project Binding is available. + - **Actors:** A3, A4 + - **Steps:** The stacked runtime starts Project Binding automatically, syncs per existing safety rules, and attaches upstream project-bound capabilities when ready. + - **Outcome:** Remote project-bound Caplets can operate against the same project context while local project Caplets remain local. + +--- + +## Acceptance Examples + +- AE1. **Covers R1, R5, R6.** Given Hosted #1 publishes `filesystem` with `shadowing: allow`, `github` with `shadowing: forbid`, and `browser` with `shadowing: namespace`, when Hosted #2 runs `caplets serve --transport http --upstream-url ` with local Caplets of the same IDs, then `filesystem` is locally available, `github` routes to upstream, and `browser` is exposed only through qualified IDs. +- AE2. **Covers R2, R15.** Given a user runs `caplets attach --transport http`, then the command refuses HTTP attach serving and points to `caplets serve --transport http --upstream-url `. +- AE3. **Covers R3, R11.** Given two MCP clients launch `caplets attach ` from different repository roots, then the stacked runtime resolves project Caplets independently for each session. +- AE4. **Covers R9, R10.** Given upstream Project Binding is unavailable, when a session attaches with a project root, then local project Caplets and non-project upstream Caplets remain available and diagnostics report degraded Project Binding. +- AE5. **Covers R12, R13, R14.** Given an agent config launches `caplets attach `, then upstream credentials are resolved from Remote Profiles and no credential material is embedded in the agent config. + +--- + +## Scope Boundaries + +- The first version targets one `--upstream-url` per stacked runtime; arbitrary fan-in across many upstreams is deferred. +- This does not replace Namespace Shadowing semantics; it must reuse and preserve them. +- This does not make `caplets attach` a daemon installer or service manager. +- This does not require hosted Cloud for local stacked usage. +- This does not expose local shell env or Vault values to upstream hosts except through explicit, existing runtime contracts. + +--- + +## Dependencies / Assumptions + +- Remote Profiles remain the source of truth for self-hosted and Cloud upstream credentials. +- Existing attach and native service code can be reused or refactored so `serve --upstream-url` does not create a parallel composition engine. +- Project Binding safety rules from `docs/project-binding.md` remain in force for upstream sync. +- Namespace Shadowing requirements in `docs/brainstorms/2026-06-23-namespace-shadowing-policy-requirements.md` are either implemented first or implemented as a prerequisite slice. + +--- + +## Outstanding Questions + +### Deferred to Planning + +- What compatibility window should `caplets attach --transport stdio` receive before the flag is fully rejected? +- What exact session metadata shape should carry project root from stdio attach and native integrations to an HTTP stacked runtime? +- What cycle detection and maximum stack depth should protect `--upstream-url` chains? + +--- + +## Sources / Research + +- `STRATEGY.md` anchors this under the Remote runtime and Project Binding track. +- `CONCEPTS.md` defines Remote Attach, Remote Login, Remote Profile, Namespace Shadowing Policy, Caplets Daemon, and Vault boundaries. +- `packages/core/src/cli.ts` currently exposes `--transport`, HTTP bind flags, and `--remote-url` on `caplets attach`. +- `packages/core/src/attach/options.ts` currently resolves attach remote selection together with serve transport options and CWD-derived project root. +- `packages/core/src/attach/server.ts` currently lets remote-backed attach serve either stdio or HTTP. +- `packages/core/src/native/service.ts` already composes remote and local tools, applies namespace exposure, and uses local/project config paths for namespace identity. +- `docs/project-binding.md` defines current Project Binding attach behavior and recovery states. diff --git a/docs/ideation/2026-06-23-codex-mcp-env-mitigation-ideation.html b/docs/ideation/2026-06-23-codex-mcp-env-mitigation-ideation.html new file mode 100644 index 00000000..277fc2e4 --- /dev/null +++ b/docs/ideation/2026-06-23-codex-mcp-env-mitigation-ideation.html @@ -0,0 +1,768 @@ + + + + + Ideation: Codex MCP Env Mitigation + + + +
+
+
ce-ideate · repo-grounded · 2026-06-23
+

Ideation: Codex MCP Env Mitigation

+

+ Focus: mitigate Codex MCP launch pain where the client does not inherit or pass the shell + environment, causing Caplets $env: config interpolation to fail under + caplets serve or caplets attach. +

+
+
12candidate clusters
+
7ranked survivors
+
4topic axes
+
Hightop confidence
+
+
+ +
+

Grounding Context

+
+

+ Caplets currently registers Codex with a minimal MCP command. Local setup calls + codex mcp add caplets -- caplets serve, while remote setup calls + codex mcp add caplets -- caplets attach <url>. That is simple, but it + leaves Caplets dependent on whatever environment Codex gives the child process. +

+

+ Config interpolation is centralized in packages/core/src/config.ts. + $env:NAME and ${NAME} are resolved from + process.env; missing references quarantine only the affected Caplet and + push recoverable warnings. Vault references already resolve through the same config + path, and the project explicitly frames Vault as replacing fragile agent-harness env + propagation for secret-like values. +

+

+ Remote attach has already moved in the safer direction: docs and tests prefer + caplets remote login <url> plus + caplets attach <url>, with credentials stored in Remote Profiles + rather than agent config or env. The daemon work also provides a useful precedent: when + environment becomes product behavior, Caplets models it as an explicit install-time + service contract rather than accidental inheritance. +

+

+ External MCP client patterns point the same way. Codex supports explicit per-server MCP + env configuration, and other clients commonly expose per-server env/interpolation or + wrappers. The market direction is explicit launch contracts, OAuth/HTTP where possible, + and fewer assumptions about login-shell inheritance. +

+
+
+ +
+

Topic Axes

+
    +
  • Startup launch/env
  • +
  • Config interpolation/secrets
  • +
  • Setup/repair UX
  • +
  • Long-term runtime architecture
  • +
+
+ +
+

Ranked Ideas

+ + +
+
+
+
1.
+

Codex Launch Contract

+
+ Conf 90% · Cx M +
+

+ Description: Upgrade caplets setup codex from raw command + registration into a Codex-aware launch contract. The setup flow should install explicit + non-secret MCP env/env_vars, resolved Caplets binary/config paths, a PATH strategy, and + the selected local/remote mode, while blocking secret copying and routing secret-like + references to Vault or Remote Profiles. +

+
+ + + + caplets setup + + + reads config refs + + + + + + Launch contract + + + non-secret env · PATH · config paths + + + secrets routed to Vault/Profile + + + + + + Codex MCP + + + no shell-env guess + + + + + validated by initialize probe + + +
+
+ Basis: direct: packages/core/src/cli/setup.ts currently + generates bare codex mcp add caplets -- caplets serve; Codex supports + explicit MCP server env; project docs already say Vault replaces fragile env + propagation. +
+
+ Rationale: This is the best top-level move because it uses Codex's + available mechanism without making secrets live in Codex config. It turns the + integration from implicit parent-env hope into an installed Caplets contract. +
+
+ Downsides: Requires Codex-specific config write/repair behavior and + careful secret classification. It should not become an excuse to copy tokens into MCP + env. +
+
+ Axis: startup launch/envConfidence: 90%Complexity: Medium +
+
+ +
+
+
+
2.
+

Env Debt Compiler

+
+ Conf 88% · Cx M +
+

+ Description: Add caplets doctor env-debt or + caplets setup codex --repair. It scans active config for + $env: and ${VAR}, classifies each reference as secret, + path/toolchain, runtime selector, remote credential, or unknown, then proposes the + correct durable home: Vault, explicit launch contract env, Remote Login, or manual + repair. +

+
+ Basis: direct: Caplets already detects missing env references before + validation, quarantines only affected Caplets, and has Vault diagnostics that produce + redacted repair commands. +
+
+ Rationale: The product should not ask the user to know which values + belong in Codex env versus Vault versus Remote Profiles. Caplets has enough context to + turn env references into a repair queue. +
+
+ Downsides: Classification is imperfect. The safe version should default + to dry-run, avoid printing raw values, and only auto-apply non-secret migrations. +
+
+ Axis: config interpolation/secretsConfidence: 88%Complexity: Medium +
+
+ +
+
+
+
3.
+

Codex Reality Simulator

+
+ Conf 86% · Cx S/M +
+

+ Description: Add caplets doctor codex that inspects the + registered Codex MCP config, runs the exact command through a sanitized GUI-like + environment, sends a newline-delimited MCP initialize request, and compares + that result with the user's current shell. The report should distinguish command path + failure, config ref failure, Vault grant failure, Remote Profile failure, and startup + crash. +

+
+ + + + Shell run + + + interactive env + + + + + Codex-like run + + + sanitized env + + + + + same initialize probe + + + + diagnose the delta + + +
+
+ Basis: direct: prior diagnosis and repo troubleshooting both identify + raw JSON-RPC initialize against the exact launch command as the decisive + MCP startup test. +
+
+ Rationale: The current failure mode is often "works in Terminal, fails + in Codex." This tool validates the actual launch model instead of the wrong parent + process. +
+
+ Downsides: It needs Codex config inspection and a good approximation of + Codex Desktop's stripped environment. Still, even a conservative simulation is better + than today's shell-only doctor path. +
+
+ Axis: setup/repair UXConfidence: 86%Complexity: Small/Medium +
+
+ +
+
+
+
4.
+

MCP Repair Mode

+
+ Conf 82% · Cx M +
+

+ Description: When caplets serve starts but unresolved env + or Vault refs quarantine most or all Caplets, expose a minimal safe MCP surface anyway: + diagnose_startup, list_unresolved_refs, + suggest_repairs, and status resources. Broken downstream Caplets stay + hidden until repaired. +

+
+ Basis: direct: config loading already produces recoverable quarantine + warnings; doctor already formats Vault repair commands without raw values; Codex users + experience the problem as missing tools inside Codex. +
+
+ Rationale: This flips startup failure from invisible absence into an + agent-actionable degraded state. It makes Caplets useful precisely when the normal + Caplet list cannot load. +
+
+ Downsides: Must be carefully scoped so repair tools do not expose + secrets or mutate config implicitly. The first version can be read-only and + command-generating. +
+
+ Axis: setup/repair UXConfidence: 82%Complexity: Medium +
+
+ +
+
+
+
5.
+

Caplets Launch Capsule

+
+ Conf 80% · Cx M +
+

+ Description: Install a stable Caplets-owned wrapper, such as + caplets-codex-mcp or caplets launch codex-local, and register + that with Codex instead of raw caplets. The capsule reconstructs only + allowed non-secret launch env, pins the resolved executable/config paths, emits redacted + startup diagnostics, then execs serve or attach. +

+
+ Basis: direct: the prior local Codex fix used a wrapper to reconstruct + PATH and launch caplets serve; daemon code already treats persistent + environment as an explicit service boundary. +
+
+ Rationale: This is the pragmatic bridge when Codex's parent process is + unreliable and when writing direct Codex config is not enough. It gives Caplets one + launch boundary it can version, validate, and diagnose. +
+
+ Downsides: Adds another installed artifact and platform-specific PATH + concerns. It should be generated and managed by setup, not hand-authored by users. +
+
+ Axis: startup launch/envConfidence: 80%Complexity: Medium +
+
+ +
+
+
+
6.
+

Secret Ref Policy

+
+ Conf 78% · Cx S +
+

+ Description: Make the platform rule explicit: $env: is for + runtime shape, paths, ports, selectors, and toolchain knobs; $vault: is for + secrets. Catalog tests already lean this way, but docs, schema text, setup warnings, and + doctor output should all say it consistently. +

+
+ Basis: direct: the GitHub Caplet uses $vault:GH_TOKEN; + catalog guardrails already flag secret-looking $env refs; docs still + describe env interpolation broadly for stdio servers. +
+
+ Rationale: This is small but compounding. It prevents new examples and + third-party Caplets from reintroducing the exact Codex env dependency that Vault was + built to remove. +
+
+ Downsides: Secret detection can produce false positives. Treat warnings + as guidance first, then tighten catalog enforcement where intent is clear. +
+
+ Axis: config interpolation/secretsConfidence: 78%Complexity: Small +
+
+ +
+
+
+
7.
+

Local Runtime Bridge

+
+ Conf 62% · Cx H +
+

+ Description: Longer term, make Codex launch only a thin stdio adapter + while the real local Caplets runtime lives in a managed daemon or local control plane. + The daemon owns install-time env, Vault resolution, Remote Profiles, logs, reloads, and + health; Codex only speaks to the bridge. +

+
+ Basis: direct: Caplets already has daemon install-time env contracts, + launchd/systemd/Windows service logic, validation, status, and logs; external MCP + patterns increasingly move sensitive auth toward HTTP/OAuth instead of local + child-process inheritance. +
+
+ Rationale: This addresses the root architecture: agent clients are poor + process supervisors. A thin adapter makes MCP startup failures smaller and gives Caplets + a durable runtime boundary across clients. +
+
+ Downsides: Large scope and only indirectly required for the current + pain. It should follow after launch contracts, migration, and diagnostics prove the + shape. +
+
+ Axis: long-term runtime architectureConfidence: 62%Complexity: High +
+
+
+ +
+

Rejection Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#IdeaReason Rejected
1Profile-only remote attach + Sound and already mostly product direction; folded into the launch contract and + secret policy rather than kept as a standalone idea. +
2Remote-first Codex escape hatch + Useful option but too narrow as a primary mitigation; users still need local Caplets + to work. +
3Machine-readable Caplet requirements + Strong long-term leverage, but better as an extension of Env Debt Compiler after the + migration categories are proven. +
4First-run failure receipt + Good support affordance, but below the ambition floor compared with live preflight + and repair mode. +
5No-parent-env mode + Verifier marked weak; it overcorrects because non-secret PATH, HOME, and toolchain + selectors still have legitimate env-shaped uses. +
6Raw Codex env copying + Rejected because it would normalize secrets in MCP/client config, contradicting + Vault and Remote Profile direction. +
7Generic dotenv sourcing + Too vague and risky as a default. A managed launch capsule can support explicit + imports later without making dotenv the product contract. +
8Catalog-only Vault policy + Too small alone; retained as broader Secret Ref Policy covering docs, setup, schema, + and doctor. +
+
+ +
+ Composed 2026-06-23 by ce-ideate from repo-grounded analysis in + /Users/ianpascoe/.codex/worktrees/4298/caplets. +
+
+ + diff --git a/docs/native-integrations.md b/docs/native-integrations.md index 09c0a178..16122b36 100644 --- a/docs/native-integrations.md +++ b/docs/native-integrations.md @@ -2,7 +2,7 @@ Native integrations use the same Project Binding vocabulary as the CLI. -Explicit remote mode is eager. If a user configures a remote service and the remote Project Binding path cannot start, the integration fails hard so the caller sees the configuration problem. +Explicit remote mode is eager. If a user configures a remote service and the remote Project Binding path cannot start outside stacked serve, the integration fails hard so the caller sees the configuration problem. Auto or configured hosted behavior is lazy. The native integration can start local Caplets immediately, then attach hosted Project Binding metadata when the remote side becomes available. When the lazy path fails, local Caplets remain available and the warning points to `caplets doctor`. @@ -15,9 +15,9 @@ OpenCode and Pi use the same resolver as `caplets attach`. - `CAPLETS_MODE=cloud` requires `CAPLETS_REMOTE_URL` pointing at Caplets Cloud and uses the saved Remote Profile from `caplets remote login `. - `CAPLETS_MODE=auto` treats Cloud URLs as Cloud, non-Cloud remote URLs as self-hosted, and no remote URL as local. -Cloud mode starts Project Binding automatically for the current project and overlays local/project Caplets over the remote workspace. +Cloud mode starts Project Binding automatically for the current project and overlays local/project Caplets over the remote workspace. A stacked HTTP runtime started with `caplets serve --transport http --upstream-url ` also attempts upstream Project Binding for each attach or native session that supplies a project root. If the upstream binding path is unavailable, local project Caplets and non-project upstream Caplets remain available and the diagnostic points to `caplets doctor`. -`caplets attach` and native remote integrations connect to the remote `/v1/attach` API for the Caplets runtime surface. Ordinary MCP clients continue to use `/v1/mcp`, which remains governed by configured exposure policy. +`caplets attach` and native remote integrations connect to the remote `/v1/attach` API for the Caplets runtime surface. `caplets attach ` is stdio-only; HTTP serving belongs to `caplets serve`. Ordinary MCP clients continue to use `/v1/mcp`, which remains governed by configured exposure policy. Native metadata should expose: diff --git a/docs/plans/2026-06-23-002-feat-stacked-remote-runtime-plan.md b/docs/plans/2026-06-23-002-feat-stacked-remote-runtime-plan.md new file mode 100644 index 00000000..e0cfcf79 --- /dev/null +++ b/docs/plans/2026-06-23-002-feat-stacked-remote-runtime-plan.md @@ -0,0 +1,188 @@ +--- +title: "feat: Add stacked remote runtime" +type: feat +date: 2026-06-23 +origin: docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md +--- + +# feat: Add stacked remote runtime + +## Summary + +Implement a stacked HTTP runtime where `caplets serve --transport http --upstream-url ` composes local, project, and upstream Caplets, while `caplets attach ` becomes a stdio-only session adapter that supplies project context. + +--- + +## Problem Frame + +Codex can launch MCP commands without the user's expected shell environment, which makes env-interpolated Caplets config unreliable when the agent process starts the runtime directly. A long-running Caplets-controlled HTTP runtime can own env, Vault, Remote Profile, health, reload, and Project Binding behavior, leaving the MCP client to launch a thin stdio adapter. + +--- + +## Requirements + +**Command contract** + +- PR1. `caplets serve --transport http --upstream-url ` starts the stacked runtime described by the origin requirements. +- PR2. `caplets attach ` is stdio-only for normal MCP use and no longer presents HTTP serving as an attach mode. +- PR3. Attach and native sessions carry project root into the stacked runtime without relying on the runtime process CWD. + +**Runtime composition** + +- PR4. The stacked runtime composes local user/global Caplets, session project Caplets, and upstream Caplets through the same local/remote composition semantics used by Remote Attach. +- PR5. Existing upstream-authored shadowing behavior remains authoritative for collisions. +- PR6. Upstream Project Binding starts automatically when a project root and upstream support are present. + +**Safety and diagnostics** + +- PR7. Removed or deprecated attach HTTP options fail with guidance to `caplets serve --transport http --upstream-url `. +- PR8. Project Binding degradation leaves safe local and upstream capabilities available with a recoverable diagnostic. +- PR9. Agent configs and attach session metadata do not contain remote credentials or local secret values. + +--- + +## Key Technical Decisions + +- **KTD1. Model `--upstream-url` as a serve composition mode.** The command publishes a runtime and should route through the server lifecycle, while `attach` stays a per-client adapter. +- **KTD2. Reuse the native remote composition path.** `packages/core/src/native/service.ts` already resolves local and upstream tools, applies namespace routing, and handles local overlay warnings; stacked serve should wrap or extract that path instead of building a second resolver. +- **KTD3. Keep project context session-scoped.** A long-running HTTP server can serve multiple repositories, so project root must come from each attach/native session rather than server startup state. +- **KTD4. Treat attach HTTP flags as compatibility hazards.** Public help should remove them; non-stdio transport and HTTP bind flags should reject with migration guidance. +- **KTD5. Preserve Remote Profile and Project Binding ownership.** Upstream auth remains in Remote Profiles, and upstream project sync uses existing Project Binding safety rules. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + Client["MCP client"] --> Attach["caplets attach stdio adapter"] + Attach --> Runtime["stacked HTTP runtime"] + Native["native integration"] --> Runtime + Runtime --> Local["local user/global Caplets"] + Runtime --> Project["session project Caplets"] + Runtime --> Upstream["upstream Caplets host"] + Runtime --> Binding["upstream Project Binding"] + Binding --> Upstream +``` + +The runtime owns composition and diagnostics. The attach adapter owns stdio bridging and session metadata only. + +--- + +## Implementation Units + +### U1. Lock the attach command to stdio + +- **Goal:** Remove HTTP serving from the public `caplets attach` contract while keeping stdio attach behavior. +- **Requirements:** PR2, PR7 +- **Dependencies:** U2 replacement path available in the same release slice +- **Files:** `packages/core/src/cli.ts`, `packages/core/src/attach/options.ts`, `packages/core/src/attach/server.ts`, `packages/core/test/attach-cli.test.ts` +- **Approach:** Remove attach help for `--transport`, `--host`, `--port`, `--path`, `--allow-unauthenticated-http`, and `--trust-proxy` only after `serve --transport http --upstream-url ` is available in the same release slice. Reject every explicit attach transport or HTTP bind flag, including `--transport stdio`; `caplets attach ` is stdio by omission. Then enforce that attach resolution always produces stdio options. +- **Execution note:** Start with failing CLI tests for help output and rejected HTTP attach usage. +- **Patterns to follow:** Existing hidden `--remote-url` compatibility in `packages/core/src/cli.ts`; current attach tests in `packages/core/test/attach-cli.test.ts`. +- **Test scenarios:** `caplets attach --help` omits transport and HTTP bind flags; `caplets attach --transport http`, `caplets attach --transport stdio`, and attach HTTP bind flags reject with serve-upstream guidance; `caplets attach ` still resolves stdio and Remote Profile selection; `caplets attach --once` still validates Project Binding without starting MCP stdio. +- **Verification:** Attach cannot start an HTTP server path, and existing remote attach/stdout JSON behavior remains covered. + +### U2. Add `--upstream-url` to HTTP serve + +- **Goal:** Let `caplets serve --transport http --upstream-url ` start a remote-backed composed runtime. +- **Requirements:** PR1, PR4, PR5, PR9 +- **Dependencies:** None +- **Files:** `packages/core/src/cli.ts`, `packages/core/src/serve/options.ts`, `packages/core/src/serve/index.ts`, `packages/core/src/attach/options.ts`, `packages/core/src/attach/server.ts`, `packages/core/test/cli.test.ts`, `packages/core/test/serve-options.test.ts` +- **Approach:** Extend serve option parsing with `upstreamUrl`, keep it valid only for HTTP serve, resolve upstream selection through the same Remote Profile path as attach, and route upstream-backed HTTP serve through a reusable native session factory. Stacked HTTP serve must use the same bind-address and authentication gates as existing HTTP serve; `/v1/mcp` and `/v1/attach` must reject unauthenticated requests unless the existing explicit unauthenticated HTTP opt-in is set. Normalize upstream runtime identity, propagate stack identity metadata through upstream calls, reject self-references and loops, and enforce a small maximum stack depth with recoverable diagnostics. +- **Execution note:** Add failing tests for serve help, option parsing, and remote profile resolution before implementation. +- **Patterns to follow:** `resolveAttachServeOptions` for remote selection and auth plumbing; `serveHttpWithSessionFactory` in `packages/core/src/serve/http.ts` for HTTP serving with custom session construction. +- **Test scenarios:** `serve --transport http --upstream-url ` resolves HTTP defaults and upstream selection; missing Remote Profile reports the same recovery path as attach; `--upstream-url` with stdio serve rejects; self-referential upstream URLs reject with a stack-cycle diagnostic; chained runtimes reject `A -> B -> A` loops and over-depth chains with recoverable diagnostics; unauthenticated requests to stacked `/v1/mcp` and `/v1/attach` reject unless the existing unauthenticated HTTP opt-in is set; diagnostics redact Authorization headers, Remote Profile tokens, env-derived config values, and Vault values; `--remote-url` is not accepted as the new serve flag unless a deliberate hidden alias is chosen. +- **Verification:** The HTTP server path can be constructed with an upstream-backed native session without changing local-only `caplets serve`. + +### U3. Define the session-aware attach API + +- **Goal:** Make `/v1/attach` session-aware so attach clients resolve the same per-project stacked surface as MCP sessions. +- **Requirements:** PR3, PR4, PR9 +- **Dependencies:** U2 +- **Files:** `packages/core/src/serve/http.ts`, `packages/core/src/attach/server.ts`, `packages/core/src/native/service.ts`, `packages/core/test/attach-cli.test.ts`, `packages/core/test/native-remote.test.ts` +- **Approach:** Define how the stdio attach adapter creates or identifies an attach session with `projectRoot` and `projectConfigPath`, how `/v1/attach/manifest`, `/v1/attach/events`, and `/v1/attach/invoke` route to that session, and how per-session native services are keyed, closed, or expired. Local project Caplets are loaded by the stacked runtime from the session project root; Project Binding is only for upstream project-bound capabilities. The runtime must accept `projectRoot` only from authenticated attach/native sessions, canonicalize it with realpath, require project config resolution to stay under that root, and reject arbitrary project-root claims from unauthenticated HTTP requests. Session metadata must be an allowlisted non-secret shape and must not carry Authorization headers, Remote Profile tokens, env-derived config values, or Vault values. +- **Execution note:** Characterize current `/v1/attach` manifest, events, and invoke behavior before changing the projection model. +- **Patterns to follow:** Existing attach HTTP routes in `packages/core/src/serve/http.ts`; `NativeCapletsMcpSession` lifecycle in `packages/core/src/native/service.ts`. +- **Test scenarios:** `caplets attach ` creates a session before manifest resolution; `/v1/attach/manifest`, `/v1/attach/events`, and `/v1/attach/invoke` use the same session projection; session metadata matches the allowlisted non-secret shape; two attach sessions from different roots do not share project Caplets; unauthenticated HTTP requests cannot claim arbitrary project roots; project config paths outside the canonical root are rejected; closing one attach session releases its per-session native service without affecting another. +- **Verification:** Attach clients and native integrations see the composed stacked surface through attach endpoints, not only through `/v1/mcp`. + +### U4. Carry session project context through attach and native integrations + +- **Goal:** Make project root a per-session input to stacked HTTP runtimes. +- **Requirements:** PR3, PR4 +- **Dependencies:** U3 +- **Files:** `packages/core/src/attach/options.ts`, `packages/core/src/attach/server.ts`, `packages/core/src/serve/http.ts`, `packages/core/src/native/service.ts`, `packages/opencode/src/*`, `packages/pi/src/*`, `packages/core/test/attach-cli.test.ts`, `packages/core/test/native-remote.test.ts` +- **Approach:** Define the session metadata shape that carries project root from stdio attach and native integrations into the runtime. Use CWD for CLI attach, explicit integration project root where available, and existing project config resolution for local project Caplets. +- **Execution note:** Characterize current project config resolution before changing cross-session behavior. +- **Patterns to follow:** `resolveAttachServeOptions` currently uses `process.cwd()` as project root; `nativeNamespaceContext` derives local identity from config and project config paths. +- **Test scenarios:** Two attach sessions with different project roots resolve different project config paths; a native integration-supplied project root overrides CWD; absence of project root yields local user/global Caplets without project Caplets. +- **Verification:** A long-running stacked runtime does not leak one client's project Caplets into another client's session. + +### U5. Preserve shadowing and namespace behavior in stacked mode + +- **Goal:** Ensure stacked composition follows existing `forbid`, `allow`, and `namespace` collision semantics. +- **Requirements:** PR4, PR5, PR9 +- **Dependencies:** U2 +- **Files:** `packages/core/src/native/service.ts`, `packages/core/src/exposure/namespace.ts`, `packages/core/test/native-remote.test.ts`, `packages/core/test/exposure-namespace.test.ts` +- **Approach:** Feed stacked upstream and local/project sources into the existing namespace resolver with durable source identity. Add tests based on the Hosted #1 and Hosted #2 example from the requirements doc. +- **Execution note:** Write regression tests first; the expected behavior is already defined by Namespace Shadowing. +- **Patterns to follow:** Namespace requirements in `docs/brainstorms/2026-06-23-namespace-shadowing-policy-requirements.md`; resolver tests in `packages/core/test/exposure-namespace.test.ts`. +- **Test scenarios:** `shadowing: forbid` routes bare ID upstream and suppresses local same-ID; `shadowing: allow` keeps local bare-ID behavior; `shadowing: namespace` exposes qualified local and upstream IDs and rejects the bare ID; generated IDs use configured aliases. +- **Verification:** Stacked mode does not introduce a new precedence model. + +### U6. Start upstream Project Binding automatically + +- **Goal:** Bind session project context upstream when the upstream supports Project Binding. +- **Requirements:** PR6, PR8 +- **Dependencies:** U4, U5 +- **Files:** `packages/core/src/project-binding/attach.ts`, `packages/core/src/native/service.ts`, `packages/core/src/attach/server.ts`, `packages/core/src/cli/doctor.ts`, `packages/core/test/native-remote.test.ts`, `packages/core/test/attach-cli.test.ts` +- **Approach:** Extract a remote-agnostic Project Binding session manager backed by `ResolvedCapletsRemote` and the existing attach Project Binding session path, then wire Cloud presence as one credential/source variant rather than the only automatic binding path. Stacked `serve --transport http --upstream-url ` uses degraded Project Binding behavior by default: unavailable upstream binding leaves local project Caplets and non-project upstream Caplets available with diagnostics. Upstream auth and Remote Profile failures still fail closed. Existing hard-fail behavior applies only to pre-existing explicit remote paths outside stacked serve. +- **Execution note:** Add failure-path tests before wiring automatic binding so degraded behavior is explicit. +- **Patterns to follow:** `docs/native-integrations.md` documents lazy hosted Project Binding behavior; `docs/project-binding.md` defines states and recovery commands; existing attach Project Binding helpers provide the remote-agnostic session contract. +- **Test scenarios:** Project Binding starts when upstream endpoint is reachable; WebSocket upgrade preflight behavior remains accepted; unavailable upstream binding leaves local project and non-project upstream capabilities available with warning; revoked credentials still fail closed. +- **Verification:** `caplets doctor` and attach diagnostics can distinguish upstream auth failure from Project Binding degradation. + +### U7. Update docs, setup guidance, and changelog + +- **Goal:** Teach the new command split and avoid new users copying deprecated attach transport usage. +- **Requirements:** PR1, PR2, PR7 +- **Dependencies:** U1, U2, U3, U4, U5, U6 +- **Files:** `README.md`, `apps/docs/src/content/docs/remote-attach.mdx`, `apps/docs/src/content/docs/agent-integrations.mdx`, `apps/docs/src/content/docs/troubleshooting.mdx`, `docs/native-integrations.md`, `docs/project-binding.md`, `packages/core/CHANGELOG.md`, `.changeset/*` +- **Approach:** Document `serve --transport http --upstream-url` as the stacked runtime command and `attach ` as stdio-only. Add migration notes for attach HTTP flags and project-context behavior. +- **Execution note:** None. +- **Patterns to follow:** Existing Remote Attach docs and the solution note in `docs/solutions/developer-experience/self-hosted-pending-remote-login-and-attach-positional-url.md`. +- **Test scenarios:** Documentation assertions are covered by existing docs checks; add targeted string tests only if current docs tests already cover command snippets. +- **Verification:** `pnpm docs:check`, `pnpm schema:check` if option schemas changed, and focused CLI tests cover the command contract. + +--- + +## Scope Boundaries + +- This plan implements one upstream URL per stacked runtime. +- This plan does not replace Namespace Shadowing or introduce local-project-wins precedence. +- This plan does not redesign daemon installation, though daemon config may later use `serve --transport http --upstream-url`. +- This plan does not expose local env, local Vault values, or upstream credentials to agent config. + +--- + +## Risks & Dependencies + +- Namespace Shadowing must be available or carried as a prerequisite because stacked runtime correctness depends on existing collision semantics. +- Session-scoped project context may require attach HTTP API changes; that should be treated as a contract change with tests before implementation. +- Rejecting attach HTTP mode could break hidden users of `caplets attach --transport http`; the implementation should decide whether `--transport stdio` receives a compatibility warning before complete removal. +- Upstream Project Binding degradation must be explicit enough that users can distinguish "remote tools are gone" from "project-bound remote tools are unavailable." + +--- + +## Sources / Research + +- Origin requirements: `docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md`. +- Product strategy: `STRATEGY.md`, especially the Remote runtime and Project Binding track. +- Domain vocabulary: `CONCEPTS.md`. +- Current attach command and serve command definitions: `packages/core/src/cli.ts`. +- Current attach serve option resolution: `packages/core/src/attach/options.ts`. +- Current stdio/HTTP attach serving split: `packages/core/src/attach/server.ts`. +- HTTP serving session factory: `packages/core/src/serve/http.ts`. +- Existing local/upstream composition and namespace routing: `packages/core/src/native/service.ts` and `packages/core/src/exposure/namespace.ts`. +- Project Binding behavior: `docs/project-binding.md` and `docs/native-integrations.md`. diff --git a/docs/project-binding.md b/docs/project-binding.md index 6dcad11e..a4438cd2 100644 --- a/docs/project-binding.md +++ b/docs/project-binding.md @@ -12,6 +12,8 @@ Hosted Cloud and self-hosted remotes use `caplets remote login ` and a save The attach client connects to the remote `/v1/attach` API for runtime Caplet discovery and calls. `/v1/mcp` remains the ordinary agent-facing MCP endpoint and continues to honor configured exposure policy. +Stacked runtimes use the same project vocabulary. `caplets serve --transport http --upstream-url ` starts a long-running HTTP runtime, and each `caplets attach ` or native session supplies its own project root. The runtime attempts upstream Project Binding for that session when the upstream supports it, but project-local Caplets are still loaded from the session project root. + ## Binding Session Loop Foreground attach creates a server-side Binding Session through the Project Binding control API, opens the control WebSocket, emits normalized JSON events, sends periodic heartbeats, and sends a remote session-end request when interrupted. A single reconnectable WebSocket close emits a `reconnecting` event before retrying the same Binding Session. diff --git a/packages/core/src/attach/api.ts b/packages/core/src/attach/api.ts index 21a924fb..31069194 100644 --- a/packages/core/src/attach/api.ts +++ b/packages/core/src/attach/api.ts @@ -17,6 +17,14 @@ import type { ExposureSnapshot, } from "../exposure/discovery"; import { generatedToolInputJsonSchemaForCaplet } from "../generated-tool-input-schema"; +import type { NativeCapletsService } from "../native/service"; + +export const CAPLETS_ATTACH_SESSION_HEADER = "caplets-attach-session-id"; + +export type AttachSessionMetadata = { + projectRoot?: string | undefined; + projectConfigPath?: string | undefined; +}; export type AttachExportKind = | "caplet" @@ -171,6 +179,85 @@ export async function buildAttachProjection(engine: CapletsEngine): Promise { + const tools = service.listTools(); + const partial = sortAttachProjectionInput({ + caplets: tools + .filter((tool) => tool.codeModeRun !== true) + .map((tool) => ({ + stableId: `native:${tool.caplet}`, + kind: "caplet" as const, + name: tool.caplet, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: tool.annotations, + schemaHash: schemaHash(tool.inputSchema ?? null), + capletId: tool.caplet, + shadowing: tool.shadowing ?? "forbid", + })), + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + completions: [], + codeModeCaplets: nativeCodeModeCaplets(tools), + diagnostics: [], + }); + const revision = revisionFor(partial); + const manifest: AttachManifest = { + version: 1, + revision, + generatedAt: new Date().toISOString(), + ...withRevisionExportIds(revision, partial), + }; + return { + manifest, + routes: routesFor(manifest), + }; +} + +function nativeCodeModeCaplets( + tools: ReturnType, +): Array> { + return tools.flatMap((tool) => + (tool.codeModeCaplets ?? []).map((caplet) => ({ + stableId: `native-code-mode:${caplet.id}`, + kind: "caplet" as const, + name: caplet.name, + title: caplet.name, + description: caplet.description, + schemaHash: null, + capletId: caplet.id, + shadowing: caplet.shadowing ?? "forbid", + })), + ); +} + +export async function invokeNativeAttachExport( + service: NativeCapletsService, + projection: AttachProjection, + request: AttachInvokeRequest, +): Promise { + if (request.revision !== projection.manifest.revision) { + throw new CapletsError("ATTACH_MANIFEST_STALE", "Attach manifest revision is stale."); + } + const route = projection.routes.get(request.exportId); + if (!route || route.kind !== request.kind) { + throw new CapletsError("ATTACH_EXPORT_NOT_FOUND", "Attach export was not found."); + } + if (route.kind !== "caplet") { + throw new CapletsError( + "REQUEST_INVALID", + "Native attach sessions only support Caplet exports.", + ); + } + return await service.execute(route.capletId, request.input); +} + export async function invokeAttachExport( engine: CapletsEngine, projection: AttachProjection, diff --git a/packages/core/src/attach/server.ts b/packages/core/src/attach/server.ts index 099669b8..a4f280e3 100644 --- a/packages/core/src/attach/server.ts +++ b/packages/core/src/attach/server.ts @@ -35,6 +35,7 @@ function createAttachNativeService(options: AttachServeOptions, io: AttachServeI return createNativeCapletsService({ mode: options.selection.kind === "hosted_cloud" ? "cloud" : "remote", configPath: options.configPath, + projectRoot: options.projectRoot, projectConfigPath: options.projectConfigPath, ...(options.authDir ? { authDir: options.authDir } : {}), remote: { diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 86c5ea8d..7cfd5773 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -276,6 +276,35 @@ function attachRemoteUrlFromArgs( return positionalUrl ?? legacyRemoteUrl; } +function rejectAttachHttpServeFlags(options: { + transport?: string; + host?: string; + port?: string; + path?: string; + allowUnauthenticatedHttp?: boolean; + trustProxy?: boolean; +}): void { + const invalid = [ + options.transport !== undefined ? "--transport" : undefined, + options.host !== undefined ? "--host" : undefined, + options.port !== undefined ? "--port" : undefined, + options.path !== undefined ? "--path" : undefined, + options.allowUnauthenticatedHttp === true ? "--allow-unauthenticated-http" : undefined, + options.trustProxy === true ? "--trust-proxy" : undefined, + ].filter((value): value is string => value !== undefined); + if (invalid.length === 0) return; + throw new CapletsError( + "REQUEST_INVALID", + `caplets attach is stdio-only; ${invalid.join(", ")} ${invalid.length === 1 ? "is" : "are"} no longer supported. Use caplets serve --transport http --upstream-url to start an HTTP stacked runtime.`, + ); +} + +export const rejectAttachHttpServeFlagsForTests = rejectAttachHttpServeFlags; + +function hiddenOption(flags: string, description: string): Option { + return new Option(flags, description).hideHelp(); +} + function remoteServerCredentialStore( statePath: string | undefined, env: NodeJS.ProcessEnv | Record, @@ -1116,6 +1145,10 @@ export function createProgram(io: CliIO = {}): Command { .option("--port ", "HTTP bind port") .option("--path ", "HTTP service base path") .option("--remote-state-path ", "server-owned remote credential state directory") + .option( + "--upstream-url ", + "upstream Caplets runtime URL to compose with this HTTP service", + ) .option( "--allow-unauthenticated-http", "allow unauthenticated HTTP serving on non-loopback hosts", @@ -1128,6 +1161,7 @@ export function createProgram(io: CliIO = {}): Command { port?: string; path?: string; remoteStatePath?: string; + upstreamUrl?: string; allowUnauthenticatedHttp?: boolean; trustProxy?: boolean; }) => { @@ -1304,10 +1338,10 @@ export function createProgram(io: CliIO = {}): Command { .command(cliCommands.attach) .description("Start a remote-backed Caplets MCP server.") .argument("[url]", "remote Caplets service base URL") - .option("--transport ", "server transport: stdio or http") - .option("--host ", "HTTP bind host") - .option("--port ", "HTTP bind port") - .option("--path ", "HTTP service base path") + .addOption(hiddenOption("--transport ", "server transport: stdio or http")) + .addOption(hiddenOption("--host ", "HTTP bind host")) + .addOption(hiddenOption("--port ", "HTTP bind port")) + .addOption(hiddenOption("--path ", "HTTP service base path")) .addOption( new Option( "--remote-url ", @@ -1315,11 +1349,13 @@ export function createProgram(io: CliIO = {}): Command { ).hideHelp(), ) .option("--workspace ", "hosted Cloud workspace ID or slug") - .option( - "--allow-unauthenticated-http", - "allow unauthenticated HTTP serving on non-loopback hosts", + .addOption( + hiddenOption( + "--allow-unauthenticated-http", + "allow unauthenticated HTTP serving on non-loopback hosts", + ), ) - .option("--trust-proxy", "trust X-Forwarded-* headers from a reverse proxy") + .addOption(hiddenOption("--trust-proxy", "trust X-Forwarded-* headers from a reverse proxy")) .option("--json", "print JSON status events") .option("--verbose", "print detailed attach diagnostics") .option("--once", "validate Project Binding once and exit") @@ -1343,6 +1379,7 @@ export function createProgram(io: CliIO = {}): Command { }, ) => { try { + rejectAttachHttpServeFlags(options); const remoteUrl = attachRemoteUrlFromArgs(url, options.remoteUrl); const attachOptions = { ...options, diff --git a/packages/core/src/native/remote.ts b/packages/core/src/native/remote.ts index 0bb16089..44c0110b 100644 --- a/packages/core/src/native/remote.ts +++ b/packages/core/src/native/remote.ts @@ -4,7 +4,13 @@ import { directResourceUriMatchesTemplate, } from "../exposure/direct-names"; import { generatedToolInputJsonSchemaForCaplet, operations } from "../generated-tool-input-schema"; -import type { AttachCodeModeCaplet, AttachManifest, AttachManifestExport } from "../attach/api"; +import { + CAPLETS_ATTACH_SESSION_HEADER, + type AttachCodeModeCaplet, + type AttachManifest, + type AttachManifestExport, + type AttachSessionMetadata, +} from "../attach/api"; import type { CapletShadowingPolicy } from "../config"; import { CodeModeJournalStore } from "../code-mode/journal"; import { runCodeMode } from "../code-mode/runner"; @@ -58,10 +64,13 @@ export type RemoteCapletsClientOptions = ResolvedNativeCapletsServiceOptions & { export type SdkRemoteCapletsClientOptions = RemoteCapletsClientOptions["remote"] & { resolveRuntimeOptions?: () => Promise; + attachSessionMetadata?: AttachSessionMetadata | undefined; authKind?: "self_hosted_remote" | "hosted_cloud"; writeErr?: (value: string) => void; }; +const ATTACH_SESSION_UNSUPPORTED_RETRY_MS = 60_000; + export type RemoteNativeCapletsServiceOptions = { client: RemoteCapletsClient; clientFactory?: () => RemoteCapletsClient; @@ -79,6 +88,9 @@ export function createSdkRemoteCapletsClient( let eventsAbort: AbortController | undefined; let eventsStartInFlight: Promise | undefined; let eventsReconnectTimer: ReturnType | undefined; + let attachSessionId: string | undefined; + let attachSessionInFlight: Promise | undefined; + let attachSessionsUnsupportedUntil = 0; let closed = false; const resolveRuntimeOptions = async (): Promise => { @@ -89,12 +101,14 @@ export function createSdkRemoteCapletsClient( runtimeOptions.fetch ?? fetch; const fetchCurrentManifest = async (): Promise => { - const runtimeOptions = await resolveRuntimeOptions(); - return await fetchAttachManifest( - runtimeOptions.url, - runtimeOptions.requestInit, - fetchFor(runtimeOptions), - ); + return await withAttachSessionRetry(async (runtimeOptions, sessionId) => { + return await fetchAttachManifest( + runtimeOptions.url, + runtimeOptions.requestInit, + sessionId, + fetchFor(runtimeOptions), + ); + }); }; const invokeCurrentExport = async (body: { @@ -103,13 +117,68 @@ export function createSdkRemoteCapletsClient( exportId: string; input: unknown; }): Promise => { - const runtimeOptions = await resolveRuntimeOptions(); - return await invokeAttachExport( + return await withAttachSessionRetry(async (runtimeOptions, sessionId) => { + return await invokeAttachExport( + runtimeOptions.url, + runtimeOptions.requestInit, + sessionId, + fetchFor(runtimeOptions), + body, + ); + }); + }; + + const ensureAttachSession = async ( + runtimeOptions: RemoteCapletsClientOptions["remote"], + ): Promise => { + if (!options.attachSessionMetadata) return undefined; + if (attachSessionsUnsupportedUntil > Date.now()) return undefined; + attachSessionsUnsupportedUntil = 0; + if (attachSessionId) return attachSessionId; + if (attachSessionInFlight) return await attachSessionInFlight; + attachSessionInFlight = createAttachSession( runtimeOptions.url, runtimeOptions.requestInit, fetchFor(runtimeOptions), - body, + options.attachSessionMetadata, ); + try { + attachSessionId = await attachSessionInFlight; + if (!attachSessionId) { + attachSessionsUnsupportedUntil = Date.now() + ATTACH_SESSION_UNSUPPORTED_RETRY_MS; + } + return attachSessionId; + } finally { + attachSessionInFlight = undefined; + } + }; + + const withAttachSessionRetry = async ( + operation: ( + runtimeOptions: RemoteCapletsClientOptions["remote"], + attachSessionId: string | undefined, + ) => Promise, + ): Promise => { + const runtimeOptions = await resolveRuntimeOptions(); + const sessionId = await ensureAttachSession(runtimeOptions); + if (closed) { + throw new CapletsError("SERVER_UNAVAILABLE", "Remote Caplets client is closed."); + } + try { + return await operation(runtimeOptions, sessionId); + } catch (error) { + if ( + !options.attachSessionMetadata || + attachSessionsUnsupportedUntil > Date.now() || + !attachSessionId || + !isAttachSessionNotFound(error) + ) { + throw error; + } + attachSessionId = undefined; + const nextSessionId = await ensureAttachSession(runtimeOptions); + return await operation(runtimeOptions, nextSessionId); + } }; const clearEventsReconnectTimer = () => { @@ -135,6 +204,7 @@ export function createSdkRemoteCapletsClient( eventsAbort = startAttachEvents( runtimeOptions.url, runtimeOptions.requestInit, + await ensureAttachSession(runtimeOptions), fetchFor(runtimeOptions), listeners, (closedAbort, retry) => { @@ -236,6 +306,20 @@ export function createSdkRemoteCapletsClient( eventsAbort?.abort(); eventsAbort = undefined; listeners.clear(); + const pendingSessionId = await attachSessionInFlight?.catch(() => undefined); + const sessionId = attachSessionId ?? pendingSessionId; + attachSessionId = undefined; + if (sessionId) { + await (async () => { + const runtimeOptions = await resolveRuntimeOptions(); + await closeAttachSession( + runtimeOptions.url, + runtimeOptions.requestInit, + fetchFor(runtimeOptions), + sessionId, + ); + })().catch(() => undefined); + } }, }; } @@ -494,28 +578,85 @@ function nativeToolRouteId(tool: RemoteCapletsTool): string { async function fetchAttachManifest( attachUrl: URL, requestInit: RequestInit | undefined, + attachSessionId: string | undefined, fetchImpl: typeof fetch, ): Promise { + const headers = attachHeaders(requestInit, attachSessionId); const response = await fetchImpl(new URL("manifest", slashUrl(attachUrl)), { ...requestInit, method: "GET", + headers, }); if (!response.ok) { + let payload: unknown; + try { + payload = (await response.json()) as unknown; + } catch { + payload = { error: { message: `Caplets attach manifest returned HTTP ${response.status}.` } }; + } + throw attachPayloadError(payload, response.status, "manifest"); + } + return (await response.json()) as AttachManifest; +} + +async function createAttachSession( + attachUrl: URL, + requestInit: RequestInit | undefined, + fetchImpl: typeof fetch, + metadata: AttachSessionMetadata, +): Promise { + const headers = new Headers(requestInit?.headers); + headers.set("content-type", "application/json"); + const response = await fetchImpl(new URL("sessions", slashUrl(attachUrl)), { + ...requestInit, + method: "POST", + headers, + body: JSON.stringify(metadata), + }); + if (!response.ok) { + if (response.status === 404) return undefined; throw new CapletsError( "SERVER_UNAVAILABLE", - `Caplets attach manifest returned HTTP ${response.status}.`, + `Caplets attach session returned HTTP ${response.status}.`, ); } - return (await response.json()) as AttachManifest; + const payload = (await response.json()) as unknown; + if (!isPlainObject(payload) || typeof payload.sessionId !== "string") { + throw new CapletsError("SERVER_UNAVAILABLE", "Caplets attach session response was invalid."); + } + return payload.sessionId; +} + +async function closeAttachSession( + attachUrl: URL, + requestInit: RequestInit | undefined, + fetchImpl: typeof fetch, + attachSessionId: string, +): Promise { + await fetchImpl(new URL(`sessions/${encodeURIComponent(attachSessionId)}`, slashUrl(attachUrl)), { + ...requestInit, + method: "DELETE", + headers: attachHeaders(requestInit, attachSessionId), + }); +} + +function attachHeaders( + requestInit: RequestInit | undefined, + attachSessionId: string | undefined, +): Headers { + const headers = new Headers(requestInit?.headers); + if (attachSessionId) headers.set(CAPLETS_ATTACH_SESSION_HEADER, attachSessionId); + return headers; } async function invokeAttachExport( attachUrl: URL, requestInit: RequestInit | undefined, + attachSessionId: string | undefined, fetchImpl: typeof fetch, body: { revision: string; kind: string; exportId: string; input: unknown }, ): Promise { - const headers = new Headers(requestInit?.headers); + const headers = attachHeaders(requestInit, attachSessionId); headers.set("content-type", "application/json"); const response = await fetchImpl(new URL("invoke", slashUrl(attachUrl)), { ...requestInit, @@ -531,12 +672,13 @@ async function invokeAttachExport( throw attachPayloadError( { error: { message: `Caplets attach invoke returned HTTP ${response.status}.` } }, response.status, + "invoke", ); } throw error; } if (!response.ok) { - throw attachPayloadError(payload, response.status); + throw attachPayloadError(payload, response.status, "invoke"); } if (isPlainObject(payload) && payload.ok === true && "data" in payload) { return payload.data; @@ -874,12 +1016,12 @@ function slashUrl(url: URL): URL { return next; } -function attachPayloadError(payload: unknown, status: number): Error { +function attachPayloadError(payload: unknown, status: number, endpoint: string): Error { const error = isPlainObject(payload) && isPlainObject(payload.error) ? payload.error : undefined; const message = typeof error?.message === "string" ? error.message - : `Caplets attach invoke returned HTTP ${status}.`; + : `Caplets attach ${endpoint} returned HTTP ${status}.`; const thrown = new Error(message) as Error & { status?: number; code?: unknown }; thrown.status = status; if (error && "code" in error) thrown.code = error.code; @@ -890,9 +1032,18 @@ function isAttachManifestStale(error: unknown): boolean { return isPlainObject(error) && error.code === "ATTACH_MANIFEST_STALE"; } +function isAttachSessionNotFound(error: unknown): boolean { + const candidate = error as { code?: unknown }; + return ( + candidate.code === "REQUEST_INVALID" && + /\battach session was not found\b/iu.test(errorMessage(error)) + ); +} + function startAttachEvents( attachUrl: URL, requestInit: RequestInit | undefined, + attachSessionId: string | undefined, fetchImpl: typeof fetch, listeners: Set<() => void>, onClose: (abort: AbortController, retry: boolean) => void, @@ -901,9 +1052,11 @@ function startAttachEvents( let retry = true; void (async () => { try { + const headers = attachHeaders(requestInit, attachSessionId); const response = await fetchImpl(new URL("events", slashUrl(attachUrl)), { ...requestInit, method: "GET", + headers, signal: abort.signal, }); if (!response.ok) { diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index 28ca447f..1ddb9152 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -1,3 +1,4 @@ +import { resolve as resolvePath } from "node:path"; import type { NativeCapletsServiceResolutionInput } from "./options"; import { resolveNativeCapletsServiceOptions, @@ -65,6 +66,7 @@ let hasWarnedRemoteProjectBindingFallback = false; export type NativeCapletsServiceOptions = NativeCapletsServiceResolutionInput & { configPath?: string; + projectRoot?: string; projectConfigPath?: string; authDir?: string; exposeLocalArtifactPaths?: boolean; @@ -617,6 +619,11 @@ type ResolvedNativeRemoteOptions = Extract< { remote: unknown } >["remote"]; +type NativeProjectBindingManager = Pick< + ProjectBindingSessionManager, + "start" | "close" | "updateAllowedCapletIds" +>; + function createLocalOverlayService(options: NativeCapletsServiceOptions): NativeCapletsService { const localOptions = { ...options, @@ -648,7 +655,7 @@ function createCompositeRemoteParts( options: NativeCapletsServiceOptions, authKind: "self_hosted_remote" | "hosted_cloud", resolveRuntimeRemoteOptions?: () => Promise, -): { remote: NativeCapletsService; presence?: ProjectBindingSessionManager } { +): { remote: NativeCapletsService; presence?: NativeProjectBindingManager } { const client = createRemoteClient(remoteOptions, options, authKind, resolveRuntimeRemoteOptions); const remote = new RemoteNativeCapletsService({ client, @@ -658,7 +665,12 @@ function createCompositeRemoteParts( authKind, ...(options.writeErr ? { writeErr: options.writeErr } : {}), }); - const presence = createProjectBindingSessionManager(remoteOptions.cloud, local, options); + const presence = createProjectBindingSessionManager( + remoteOptions.cloud, + remoteOptions, + local, + options, + ); return { remote, ...(presence ? { presence } : {}) }; } @@ -671,15 +683,27 @@ function createRemoteClient( if (options.remoteClientFactory) { return options.remoteClientFactory(remoteOptions); } + const attachSessionMetadata = attachSessionMetadataForOptions(options); const sdkOptions: SdkRemoteCapletsClientOptions = { ...remoteOptions, authKind, ...(options.writeErr ? { writeErr: options.writeErr } : {}), + ...(attachSessionMetadata ? { attachSessionMetadata } : {}), ...(resolveRuntimeRemoteOptions ? { resolveRuntimeOptions: resolveRuntimeRemoteOptions } : {}), }; return createSdkRemoteCapletsClient(sdkOptions); } +function attachSessionMetadataForOptions( + options: NativeCapletsServiceOptions, +): SdkRemoteCapletsClientOptions["attachSessionMetadata"] { + if (!options.projectRoot) return undefined; + return { + projectRoot: options.projectRoot, + projectConfigPath: resolvePath(options.projectRoot, ".caplets", "config.json"), + }; +} + class ProfileBackedNativeCapletsService implements NativeCapletsService { private readonly local: NativeCapletsService; private readonly listeners = new Set(); @@ -927,7 +951,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { private readonly local: NativeCapletsService, private readonly options: NativeCapletsServiceOptions, private remoteIdentity: string, - private presence?: ProjectBindingSessionManager, + private presence?: NativeProjectBindingManager, ) { this.unsubscribeRemote = this.remote.onToolsChanged(() => this.updateMergedTools()); this.unsubscribeLocal = this.local.onToolsChanged(() => this.updateMergedTools()); @@ -987,6 +1011,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { this.local.listTools().map((tool) => tool.caplet), ); } + this.startPresence(); this.updateMergedTools(); return remoteReloaded || localReloaded; } @@ -1010,8 +1035,8 @@ class CompositeNativeCapletsService implements NativeCapletsService { async replaceRemote( remote: NativeCapletsService, - remoteIdentityOrPresence?: string | ProjectBindingSessionManager, - presence?: ProjectBindingSessionManager, + remoteIdentityOrPresence?: string | NativeProjectBindingManager, + presence?: NativeProjectBindingManager, ): Promise { const remoteIdentity = typeof remoteIdentityOrPresence === "string" ? remoteIdentityOrPresence : this.remoteIdentity; @@ -1237,10 +1262,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { private startPresence(): void { void this.presence?.start().catch((error) => { - writeErr( - this.options, - `Could not register Caplets Cloud Project Binding: ${errorMessage(error)}\n`, - ); + writeErr(this.options, `Could not start upstream Project Binding: ${errorMessage(error)}\n`); }); } } @@ -1379,33 +1401,190 @@ function remoteSuppressedCapletIds( function createProjectBindingSessionManager( cloud: ResolvedNativeRemoteOptions["cloud"], + remoteOptions: ResolvedNativeRemoteOptions, local: NativeCapletsService, options: NativeCapletsServiceOptions, -): ProjectBindingSessionManager | undefined { - if (!cloud) { +): NativeProjectBindingManager | undefined { + const allowedCapletIds = local.listTools().map((tool) => tool.caplet); + if (cloud) { + const projectRoot = cloud.projectRoot ?? findProjectRoot(); + const cloudFetch = options.remote?.fetch; + const clientOptions = { + baseUrl: cloud.url, + accessToken: cloud.accessToken, + ...(cloudFetch ? { fetch: cloudFetch } : {}), + }; + return new ProjectBindingSessionManager({ + client: new CapletsCloudClient(clientOptions), + workspaceId: cloud.workspaceId, + projectRoot, + projectFingerprint: fingerprintProjectRoot(projectRoot), + projectFiles: projectSyncFiles(projectRoot), + allowedCapletIds, + heartbeatIntervalMs: cloud.heartbeatIntervalMs, + onError: (error) => { + writeErr( + options, + `Caplets Cloud Project Binding heartbeat failed: ${errorMessage(error)}\n`, + ); + }, + }); + } + if (!options.projectRoot) { return undefined; } - const projectRoot = cloud.projectRoot ?? findProjectRoot(); - const cloudFetch = options.remote?.fetch; - const clientOptions = { - baseUrl: cloud.url, - accessToken: cloud.accessToken, - ...(cloudFetch ? { fetch: cloudFetch } : {}), - }; - return new ProjectBindingSessionManager({ - client: new CapletsCloudClient(clientOptions), - workspaceId: cloud.workspaceId, - projectRoot, - projectFingerprint: fingerprintProjectRoot(projectRoot), - projectFiles: projectSyncFiles(projectRoot), - allowedCapletIds: local.listTools().map((tool) => tool.caplet), - heartbeatIntervalMs: cloud.heartbeatIntervalMs, - onError: (error) => { - writeErr(options, `Caplets Cloud Project Binding heartbeat failed: ${errorMessage(error)}\n`); - }, + return new RemoteProjectBindingSessionManager({ + attachUrl: remoteOptions.url, + requestInit: remoteOptions.requestInit, + fetch: remoteOptions.fetch, + projectRoot: options.projectRoot, + allowedCapletIds, + heartbeatIntervalMs: 30_000, + writeErr: options.writeErr, }); } +class RemoteProjectBindingSessionManager implements NativeProjectBindingManager { + private bindingId: string | undefined; + private sessionId: string | undefined; + private allowedCapletIds: string[]; + private heartbeatTimer: ReturnType | undefined; + private startPromise: Promise | undefined; + + constructor( + private readonly options: { + attachUrl: URL; + requestInit: RequestInit; + fetch?: typeof fetch | undefined; + projectRoot: string; + allowedCapletIds: string[]; + heartbeatIntervalMs: number; + writeErr?: ((value: string) => void) | undefined; + }, + ) { + this.allowedCapletIds = [...options.allowedCapletIds]; + } + + async start(): Promise { + if (!this.startPromise) { + const start = this.register(); + this.startPromise = start; + void start.catch(() => { + if (this.startPromise === start) { + this.startPromise = undefined; + } + }); + } + return await this.startPromise; + } + + 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 updateAllowedCapletIds(allowedCapletIds: string[]): Promise { + this.allowedCapletIds = [...allowedCapletIds]; + if (!this.bindingId || !this.sessionId) return; + await this.heartbeat().catch(() => undefined); + } + + 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, + }, + }); + 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; + this.startHeartbeat(); + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => { + void this.heartbeat().catch((error) => { + this.stopHeartbeat(); + this.options.writeErr?.( + `Remote Project Binding heartbeat failed: ${errorMessage(error)}\n`, + ); + }); + }, this.options.heartbeatIntervalMs); + this.heartbeatTimer.unref?.(); + } + + private stopHeartbeat(): void { + if (!this.heartbeatTimer) return; + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + } + + private async heartbeat(): Promise { + if (!this.bindingId || !this.sessionId) return; + await this.fetchJson(projectBindingUrl(this.options.attachUrl, this.bindingId, "heartbeat"), { + method: "POST", + body: { + sessionId: this.sessionId, + state: "ready", + syncState: "idle", + allowedCapletIds: this.allowedCapletIds, + }, + }); + } + + private async fetchJson( + url: URL, + input: { method: "POST" | "DELETE"; body: unknown }, + ): Promise { + const headers = new Headers(this.options.requestInit.headers); + headers.set("content-type", "application/json"); + const response = await (this.options.fetch ?? fetch)(url, { + ...this.options.requestInit, + method: input.method, + headers, + body: JSON.stringify(input.body), + }); + if (!response.ok) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `Project Binding request failed (${response.status}).`, + ); + } + return (await response.json().catch(() => ({}))) as T; + } +} + +function projectBindingUrl(attachUrl: URL, ...segments: string[]): URL { + const url = new URL(attachUrl); + const base = url.pathname.replace(/\/+$/u, ""); + url.pathname = [base, "project-bindings", ...segments.map(encodeURIComponent)].join("/"); + url.search = ""; + url.hash = ""; + return url; +} + function createLocalOverlayConfigLoader(options: NativeCapletsServiceOptions) { let hasLoaded = false; let previousWarnings = new Set(); diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index f6b7e828..969196f7 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import { dirname } from "node:path"; +import { existsSync, realpathSync, statSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; import { StreamableHTTPTransport } from "@hono/mcp"; import { serve, type ServerType } from "@hono/node-server"; import { Hono, type MiddlewareHandler } from "hono"; @@ -10,8 +11,11 @@ import { CapletsError, toSafeError } from "../errors"; import { attachErrorResponse, buildAttachProjection, + CAPLETS_ATTACH_SESSION_HEADER, invokeAttachExport, + type AttachManifest, type AttachInvokeRequest, + type AttachSessionMetadata, } from "../attach/api"; import { dispatchRemoteCliRequest, @@ -28,6 +32,7 @@ type HttpServeIo = { control?: Omit; authFlowStore?: RemoteAuthFlowStore; sessionFactory?: HttpMcpSessionFactory; + attachSessionFactory?: HttpAttachSessionFactory; exposeAttach?: boolean; remoteCredentialStore?: RemoteServerCredentialStore; }; @@ -39,6 +44,17 @@ type HttpMcpSession = { export type HttpMcpSessionFactory = () => HttpMcpSession | Promise; +export type HttpAttachSession = { + manifest(): Promise; + invoke(request: AttachInvokeRequest): Promise; + onManifestChanged(listener: () => void): () => void; + close(): Promise; +}; + +export type HttpAttachSessionFactory = ( + metadata: AttachSessionMetadata, +) => HttpAttachSession | Promise; + type HttpSession = { server: HttpMcpSession; transport: StreamableHTTPTransport; @@ -52,6 +68,18 @@ type AttachEventStream = { close: () => void; }; +type AttachSessionRecord = { + session: HttpAttachSession; + lastUsedAt: number; +}; + +type AttachEventSource = { + manifestRevision: () => Promise; + onManifestChanged: (listener: () => void) => () => void; +}; + +const ATTACH_SESSION_IDLE_TIMEOUT_MS = 10 * 60_000; + export function createHttpServeApp( options: HttpServeOptions, engine: CapletsEngine, @@ -59,11 +87,13 @@ export function createHttpServeApp( ): CapletsHttpApp { const app = new Hono() as CapletsHttpApp; const sessions = new Map(); + const attachSessions = new Map(); const attachEventStreams = new Set(); const writeErr = io.writeErr ?? process.stderr.write.bind(process.stderr); const paths = servicePaths(options.path); const authFlowStore = io.authFlowStore ?? new RemoteAuthFlowStore(); const exposeAttach = io.exposeAttach ?? true; + const exposeAttachSessions = exposeAttach && Boolean(io.attachSessionFactory); const remoteCredentialStore = remoteCredentialStoreForOptions(options, io.remoteCredentialStore); if ( options.auth.type === "remote_credentials" && @@ -92,7 +122,7 @@ export function createHttpServeApp( name: "caplets", transport: "http", base: paths.base, - versions: [versionDiscovery(paths, exposeAttach, remote)], + versions: [versionDiscovery(paths, { exposeAttach, exposeAttachSessions }, remote)], auth: { type: options.auth.type }, ...(remote ? { remote } : {}), }); @@ -102,7 +132,7 @@ export function createHttpServeApp( const remote = remoteCredentialStore ? remoteHostMetadata(c.req.url, paths.base, options, (name) => c.req.header(name)) : undefined; - return c.json(versionDiscovery(paths, exposeAttach, remote)); + return c.json(versionDiscovery(paths, { exposeAttach, exposeAttachSessions }, remote)); }); app.get(paths.health, (c) => @@ -297,18 +327,75 @@ export function createHttpServeApp( }); if (exposeAttach) { + if (io.attachSessionFactory) { + app.post(paths.attachSessions, attachHostProtection, protectedRouteAuth, async (c) => { + try { + const parsed = await parseJsonObject(c.req.json(), "Attach session request"); + const metadata = parseAttachSessionMetadata(parsed, { + allowProjectContext: options.loopback, + }); + const sessionId = randomUUID(); + const session = await io.attachSessionFactory!(metadata); + attachSessions.set(sessionId, { session, lastUsedAt: Date.now() }); + pruneIdleAttachSessions(); + return c.json({ sessionId }, 201); + } catch (error) { + const response = attachErrorResponse(error); + return c.json(response.body, response.status); + } + }); + + app.delete( + routePath(paths.attachSessions, ":sessionId"), + attachHostProtection, + protectedRouteAuth, + async (c) => { + const sessionId = c.req.param("sessionId"); + if (!sessionId) { + return c.json({ ok: false, error: { code: "REQUEST_INVALID" } }, 400); + } + const record = attachSessions.get(sessionId); + attachSessions.delete(sessionId); + await record?.session.close(); + return c.json({ ok: true }); + }, + ); + } + app.get(paths.attachManifest, attachHostProtection, protectedRouteAuth, async (c) => { - const attachProjection = await buildAttachProjection(engine); - return c.json(attachProjection.manifest); + try { + const attachSession = attachSessionForRequest(c.req.header(CAPLETS_ATTACH_SESSION_HEADER)); + if (attachSession) return c.json(await attachSession.manifest()); + const attachProjection = await buildAttachProjection(engine); + return c.json(attachProjection.manifest); + } catch (error) { + const response = attachErrorResponse(error); + return c.json(response.body, response.status); + } }); - app.get(paths.attachEvents, attachHostProtection, protectedRouteAuth, () => - attachEventsResponse(engine, attachEventStreams), - ); + app.get(paths.attachEvents, attachHostProtection, protectedRouteAuth, (c) => { + try { + const attachSessionId = c.req.header(CAPLETS_ATTACH_SESSION_HEADER); + const attachSession = attachSessionForRequest(attachSessionId); + return attachEventsResponse(attachEventSource(engine, attachSession), attachEventStreams, { + onActivity: () => { + if (attachSessionId) touchAttachSession(attachSessionId); + }, + }); + } catch (error) { + const response = attachErrorResponse(error); + return c.json(response.body, response.status); + } + }); app.post(paths.attachInvoke, attachHostProtection, protectedRouteAuth, async (c) => { try { const request = await parseAttachInvokeRequest(c.req.json()); + const attachSession = attachSessionForRequest(c.req.header(CAPLETS_ATTACH_SESSION_HEADER)); + if (attachSession) { + return c.json({ ok: true, data: await attachSession.invoke(request) }); + } const attachProjection = await buildAttachProjection(engine); const result = await invokeAttachExport(engine, attachProjection, request); return c.json({ ok: true, data: result }); @@ -319,6 +406,33 @@ export function createHttpServeApp( }); } + function attachSessionForRequest(sessionId: string | undefined): HttpAttachSession | undefined { + pruneIdleAttachSessions(); + if (!sessionId) return undefined; + const record = attachSessions.get(sessionId); + if (!record) { + throw new CapletsError("REQUEST_INVALID", "Attach session was not found."); + } + record.lastUsedAt = Date.now(); + return record.session; + } + + function touchAttachSession(sessionId: string): void { + const record = attachSessions.get(sessionId); + if (record) record.lastUsedAt = Date.now(); + } + + function pruneIdleAttachSessions(): void { + const expiresBefore = Date.now() - ATTACH_SESSION_IDLE_TIMEOUT_MS; + for (const [sessionId, record] of attachSessions) { + if (record.lastUsedAt >= expiresBefore) continue; + attachSessions.delete(sessionId); + void record.session.close().catch((error) => { + writeErr(`Could not close idle attach session: ${errorMessage(error)}\n`); + }); + } + } + app.post(paths.control, protectedRouteAuth, async (c) => { let request: RemoteCliRequest; try { @@ -363,29 +477,16 @@ export function createHttpServeApp( }), ); - app.post(routePath(paths.projectBindings, "sessions"), protectedRouteAuth, (c) => { - const bindingId = randomUUID(); - return c.json( - { - binding: { bindingId, state: "attaching", syncState: "pending" }, - sessionId: randomUUID(), - }, - 201, - ); - }); + app.post(routePath(paths.projectBindings, "sessions"), protectedRouteAuth, (c) => + c.json(projectBindingUnsupported(), 501), + ); app.post(routePath(paths.projectBindings, ":bindingId/heartbeat"), protectedRouteAuth, (c) => - c.json({ - ok: true, - binding: { bindingId: c.req.param("bindingId"), state: "ready" }, - }), + c.json(projectBindingUnsupported(c.req.param("bindingId")), 501), ); app.delete(routePath(paths.projectBindings, ":bindingId/session"), protectedRouteAuth, (c) => - c.json({ - ok: true, - binding: { bindingId: c.req.param("bindingId"), state: "ended" }, - }), + c.json(projectBindingUnsupported(c.req.param("bindingId")), 501), ); app.get(routePath(paths.control, "auth/callback/:flowId"), async (c) => { @@ -423,6 +524,8 @@ export function createHttpServeApp( }), ); sessions.clear(); + await Promise.allSettled([...attachSessions.values()].map((record) => record.session.close())); + attachSessions.clear(); }; if (options.warnUnauthenticatedNetwork) { @@ -516,10 +619,25 @@ function remoteCredentialSourceHint( return sourceHint ? { sourceHint } : {}; } +function projectBindingUnsupported(bindingId?: string | undefined) { + return { + ok: false, + error: { + code: "UNSUPPORTED_CAPABILITY", + message: "Self-hosted Project Binding sessions are not implemented by this runtime.", + }, + ...(bindingId ? { binding: { bindingId, state: "not_attached" } } : {}), + }; +} + function firstForwardedValue(value: string | undefined): string | undefined { return value?.split(",", 1)[0]?.trim() || undefined; } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + type RemoteHostMetadata = { hostIdentity: string; audience: string; @@ -543,9 +661,11 @@ function remoteHostMetadata( function versionDiscovery( paths: ReturnType, - exposeAttach = true, + options: { exposeAttach?: boolean; exposeAttachSessions?: boolean } = {}, remote?: RemoteHostMetadata | undefined, ) { + const exposeAttach = options.exposeAttach ?? true; + const exposeAttachSessions = options.exposeAttachSessions ?? false; return { version: 1, path: paths.version, @@ -555,6 +675,7 @@ function versionDiscovery( admin: paths.control, ...(exposeAttach ? { + ...(exposeAttachSessions ? { attachSessions: paths.attachSessions } : {}), attachManifest: paths.attachManifest, attachEvents: paths.attachEvents, attachInvoke: paths.attachInvoke, @@ -612,13 +733,94 @@ function isAttachExportKind(value: string): value is AttachInvokeRequest["kind"] ); } -function attachEventsResponse( +function parseAttachSessionMetadata( + input: Record, + options: { allowProjectContext: boolean }, +): AttachSessionMetadata { + const rawProjectRoot = optionalStringField(input, "projectRoot"); + const rawProjectConfigPath = optionalStringField(input, "projectConfigPath"); + if (!options.allowProjectContext && (rawProjectRoot || rawProjectConfigPath)) { + throw new CapletsError( + "REQUEST_INVALID", + "Attach session project context is only accepted by loopback runtimes.", + ); + } + const projectRoot = canonicalProjectRoot(rawProjectRoot); + const projectConfigPath = canonicalProjectConfigPath(rawProjectConfigPath, projectRoot); + return { + ...(projectRoot ? { projectRoot } : {}), + ...(projectConfigPath ? { projectConfigPath } : {}), + }; +} + +function canonicalProjectRoot(projectRoot: string | undefined): string | undefined { + if (!projectRoot) return undefined; + try { + const canonical = realpathSync(projectRoot); + if (!statSync(canonical).isDirectory()) { + throw new Error("projectRoot is not a directory."); + } + return canonical; + } catch (error) { + throw new CapletsError("REQUEST_INVALID", "projectRoot must be an existing directory.", error); + } +} + +function canonicalProjectConfigPath( + projectConfigPath: string | undefined, + projectRoot: string | undefined, +): string | undefined { + if (!projectConfigPath) return undefined; + if (!projectRoot) { + throw new CapletsError("REQUEST_INVALID", "projectConfigPath requires projectRoot."); + } + const absoluteConfigPath = isAbsolute(projectConfigPath) + ? projectConfigPath + : resolve(projectRoot, projectConfigPath); + const candidate = existsSync(absoluteConfigPath) + ? realpathSync(absoluteConfigPath) + : resolve(absoluteConfigPath); + const expectedProjectConfigPath = resolve(projectRoot, ".caplets", "config.json"); + const expected = existsSync(expectedProjectConfigPath) + ? realpathSync(expectedProjectConfigPath) + : expectedProjectConfigPath; + if (candidate === expected) { + return expected; + } + throw new CapletsError( + "REQUEST_INVALID", + "projectConfigPath must be /.caplets/config.json.", + ); +} + +function attachEventSource( engine: CapletsEngine, + session: HttpAttachSession | undefined, +): AttachEventSource { + if (session) { + return { + manifestRevision: async () => (await session.manifest()).revision, + onManifestChanged: (listener) => session.onManifestChanged(listener), + }; + } + return { + manifestRevision: async () => (await buildAttachProjection(engine)).manifest.revision, + onManifestChanged: (listener) => + engine.onReload(() => { + listener(); + }), + }; +} + +function attachEventsResponse( + source: AttachEventSource, activeStreams: Set, + options: { onActivity?: () => void } = {}, ): Response { const encoder = new TextEncoder(); let unsubscribe: () => void = () => undefined; let activeStream: AttachEventStream | undefined; + let keepAliveTimer: ReturnType | undefined; let closed = false; const readable = new ReadableStream({ start(controller) { @@ -627,6 +829,7 @@ function attachEventsResponse( if (closed) return; closed = true; unsubscribe(); + if (keepAliveTimer) clearInterval(keepAliveTimer); if (activeStream) activeStreams.delete(activeStream); try { controller.close(); @@ -636,15 +839,26 @@ function attachEventsResponse( }, }; activeStreams.add(activeStream); + options.onActivity?.(); controller.enqueue(encoder.encode(": connected\n\n")); - unsubscribe = engine.onReload(() => { - void buildAttachProjection(engine) - .then((projection) => { + keepAliveTimer = setInterval(() => { + if (closed) return; + options.onActivity?.(); + try { + controller.enqueue(encoder.encode(": keepalive\n\n")); + } catch { + activeStream?.close(); + } + }, 30_000); + keepAliveTimer.unref?.(); + unsubscribe = source.onManifestChanged(() => { + options.onActivity?.(); + void source + .manifestRevision() + .then((revision) => { if (closed) return; controller.enqueue( - encoder.encode( - `event: manifest_changed\ndata: ${JSON.stringify({ revision: projection.manifest.revision })}\n\n`, - ), + encoder.encode(`event: manifest_changed\ndata: ${JSON.stringify({ revision })}\n\n`), ); }) .catch(() => undefined); @@ -701,13 +915,15 @@ export async function serveHttpWithSessionFactory( options: HttpServeOptions, createSession: HttpMcpSessionFactory, writeErr: (value: string) => void = (value) => process.stderr.write(value), + io: Pick = {}, ): Promise { const resolvedEngineOptions = { exposeLocalArtifactPaths: false }; const engine = new CapletsEngine(resolvedEngineOptions); const app = createHttpServeApp(options, engine, { writeErr, - exposeAttach: false, + exposeAttach: io.exposeAttach ?? false, sessionFactory: createSession, + ...(io.attachSessionFactory ? { attachSessionFactory: io.attachSessionFactory } : {}), control: { ...resolvedEngineOptions, projectCapletsRoot: resolveProjectCapletsRoot(), @@ -747,6 +963,7 @@ export function servicePaths(base: string): { mcp: string; control: string; attachManifest: string; + attachSessions: string; attachEvents: string; attachInvoke: string; projectBindings: string; @@ -768,6 +985,7 @@ export function servicePaths(base: string): { version, mcp: routePath(version, "mcp"), control: routePath(version, "admin"), + attachSessions: routePath(attach, "sessions"), attachManifest: routePath(attach, "manifest"), attachEvents: routePath(attach, "events"), attachInvoke: routePath(attach, "invoke"), diff --git a/packages/core/src/serve/index.ts b/packages/core/src/serve/index.ts index 8483537c..68dcb3ac 100644 --- a/packages/core/src/serve/index.ts +++ b/packages/core/src/serve/index.ts @@ -1,6 +1,16 @@ import type { CapletsEngineOptions } from "../engine"; -import { serveHttp } from "./http"; +import { + buildNativeAttachProjection, + invokeNativeAttachExport, + type AttachInvokeRequest, + type AttachSessionMetadata, +} from "../attach/api"; +import { createNativeCapletsService } from "../native/service"; +import type { NativeCapletsService } from "../native/service"; +import { isCapletsCloudUrl } from "../remote/options"; +import { serveHttp, serveHttpWithSessionFactory } from "./http"; import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "./options"; +import { NativeCapletsMcpSession } from "./native-session"; import { serveStdio } from "./stdio"; export { serveHttp } from "./http"; @@ -31,5 +41,60 @@ export async function serveResolvedCaplets( await serveStdio({ ...engineOptions, ...(writeErr ? { writeErr } : {}) }); return; } + if (resolved.upstreamUrl) { + await serveHttpWithUpstream(resolved, resolved.upstreamUrl, engineOptions, writeErr); + return; + } await serveHttp(resolved, { ...engineOptions, ...(writeErr ? { writeErr } : {}) }, writeErr); } + +async function serveHttpWithUpstream( + resolved: Extract, + upstreamUrl: string, + engineOptions: CapletsEngineOptions, + writeErr?: (value: string) => void, +): Promise { + await serveHttpWithSessionFactory( + resolved, + async () => { + const service = createUpstreamNativeService(upstreamUrl, engineOptions, writeErr); + await service.reload(); + return new NativeCapletsMcpSession(service); + }, + writeErr, + { + exposeAttach: true, + attachSessionFactory: async (metadata) => { + const service = createUpstreamNativeService(upstreamUrl, engineOptions, writeErr, metadata); + await service.reload(); + return nativeAttachSession(service); + }, + }, + ); +} + +function createUpstreamNativeService( + upstreamUrl: string, + engineOptions: CapletsEngineOptions, + writeErr?: (value: string) => void, + metadata: AttachSessionMetadata = {}, +): NativeCapletsService { + return createNativeCapletsService({ + ...engineOptions, + ...(metadata.projectRoot ? { projectRoot: metadata.projectRoot } : {}), + ...(metadata.projectConfigPath ? { projectConfigPath: metadata.projectConfigPath } : {}), + mode: isCapletsCloudUrl(upstreamUrl) ? "cloud" : "remote", + remote: { url: upstreamUrl }, + ...(writeErr ? { writeErr } : {}), + }); +} + +function nativeAttachSession(service: NativeCapletsService) { + return { + manifest: async () => (await buildNativeAttachProjection(service)).manifest, + invoke: async (request: AttachInvokeRequest) => + await invokeNativeAttachExport(service, await buildNativeAttachProjection(service), request), + onManifestChanged: (listener: () => void) => service.onToolsChanged(() => listener()), + close: async () => await service.close(), + }; +} diff --git a/packages/core/src/serve/options.ts b/packages/core/src/serve/options.ts index 2d602a90..b3491120 100644 --- a/packages/core/src/serve/options.ts +++ b/packages/core/src/serve/options.ts @@ -11,6 +11,7 @@ export type RawServeOptions = { port?: string | number; path?: string; remoteStatePath?: string; + upstreamUrl?: string; allowUnauthenticatedHttp?: boolean; trustProxy?: boolean; }; @@ -27,6 +28,7 @@ export type HttpServeOptions = { publicOrigin?: string | undefined; auth: HttpServeAuthOptions; remoteCredentialStateDir?: string | undefined; + upstreamUrl?: string | undefined; allowUnauthenticatedHttp: boolean; warnUnauthenticatedNetwork: boolean; loopback: boolean; @@ -48,10 +50,21 @@ const HTTP_ONLY_OPTIONS = [ "port", "path", "remoteStatePath", + "upstreamUrl", "allowUnauthenticatedHttp", "trustProxy", ] as const; +const HTTP_ONLY_OPTION_FLAGS = { + host: "--host", + port: "--port", + path: "--path", + remoteStatePath: "--remote-state-path", + upstreamUrl: "--upstream-url", + allowUnauthenticatedHttp: "--allow-unauthenticated-http", + trustProxy: "--trust-proxy", +} as const satisfies Record<(typeof HTTP_ONLY_OPTIONS)[number], string>; + export function resolveServeOptions( raw: RawServeOptions, env: ServeEnv = process.env, @@ -62,7 +75,7 @@ export function resolveServeOptions( if (invalid.length > 0) { throw new CapletsError( "REQUEST_INVALID", - `${invalid.map((key) => `--${key}`).join(", ")} ${invalid.length === 1 ? "is" : "are"} only valid with --transport http`, + `${invalid.map((key) => HTTP_ONLY_OPTION_FLAGS[key]).join(", ")} ${invalid.length === 1 ? "is" : "are"} only valid with --transport http`, ); } return { transport }; @@ -78,6 +91,15 @@ export function resolveServeOptions( nonEmpty(raw.remoteStatePath, "--remote-state-path") ?? nonEmpty(env.CAPLETS_REMOTE_SERVER_STATE_DIR, "CAPLETS_REMOTE_SERVER_STATE_DIR") ?? join(DEFAULT_AUTH_DIR, "remote-server"); + const upstreamUrl = nonEmpty(raw.upstreamUrl, "--upstream-url"); + if (upstreamUrl) { + rejectSelfReferentialUpstream(upstreamUrl, { + ...(serverUrl ? { origin: serverUrl.origin } : {}), + host, + port, + path, + }); + } const loopback = isLoopbackHost(host); const auth: HttpServeAuthOptions = @@ -92,6 +114,7 @@ export function resolveServeOptions( ...(serverUrl ? { publicOrigin: serverUrl.origin } : {}), auth, ...(auth.type === "remote_credentials" ? { remoteCredentialStateDir } : {}), + ...(upstreamUrl ? { upstreamUrl } : {}), allowUnauthenticatedHttp: raw.allowUnauthenticatedHttp === true, warnUnauthenticatedNetwork: !loopback && auth.type === "development_unauthenticated", loopback, @@ -154,6 +177,66 @@ function serverUrlHost(url: URL | undefined): string | undefined { return url?.hostname.replace(/^\[(.*)\]$/u, "$1"); } +function rejectSelfReferentialUpstream( + upstreamUrl: string, + local: { origin?: string; host: string; port: number; path: string }, +): void { + const upstream = parseServerBaseUrl(upstreamUrl); + const localBase = localServeBaseUrl(local); + if (sameServerBase(upstream, localBase)) { + throw new CapletsError( + "REQUEST_INVALID", + "--upstream-url must not point back to this runtime.", + ); + } +} + +function localServeBaseUrl(local: { origin?: string; host: string; port: number; path: string }) { + const origin = local.origin ?? `http://${formatHost(local.host)}:${local.port}`; + const url = new URL(origin); + url.pathname = local.path; + url.search = ""; + url.hash = ""; + return url; +} + +function sameServerBase(left: URL, right: URL): boolean { + return ( + left.protocol === right.protocol && + sameHost(left.hostname, right.hostname) && + effectivePort(left) === effectivePort(right) && + normalizePath(left.pathname) === normalizePath(right.pathname) + ); +} + +function sameHost(left: string, right: string): boolean { + if (left === right) return true; + const normalizedLeft = normalizeLoopbackHost(left); + const normalizedRight = normalizeLoopbackHost(right); + return normalizedLeft !== undefined && normalizedLeft === normalizedRight; +} + +function normalizeLoopbackHost(host: string): "loopback" | undefined { + const normalized = host.toLowerCase().replace(/^\[(.*)\]$/u, "$1"); + if (normalized === "localhost" || normalized === "::1") return "loopback"; + if (normalized === "0.0.0.0" || normalized === "::") return "loopback"; + if (/^127(?:\.\d{1,3}){3}$/u.test(normalized)) return "loopback"; + return undefined; +} + +function effectivePort(url: URL): string { + if (url.port) return url.port; + return url.protocol === "https:" ? "443" : "80"; +} + +function normalizePath(path: string): string { + return path === "/" ? "/" : path.replace(/\/+$/u, ""); +} + +function formatHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + function nonEmpty(value: string | undefined, label: string): string | undefined { if (value === undefined) { return undefined; diff --git a/packages/core/test/attach-api.test.ts b/packages/core/test/attach-api.test.ts index 9f2ffa31..f0cfad78 100644 --- a/packages/core/test/attach-api.test.ts +++ b/packages/core/test/attach-api.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import { buildAttachProjection, + buildNativeAttachProjection, invokeAttachExport, type AttachProjection, } from "../src/attach/api"; import type { CapletsEngine } from "../src/engine"; +import type { NativeCapletsService } from "../src/native/service"; describe("Attach API dispatch", () => { it("sorts attach exports before hashing revisions", async () => { @@ -139,6 +141,108 @@ describe("Attach API dispatch", () => { ]); }); + it("preserves native stacked Caplet IDs and shadowing policies in attach manifests", async () => { + const service = { + listTools: () => [ + { + caplet: "filesystem", + toolName: "caplets__filesystem", + title: "Local Filesystem", + description: "Local project filesystem.", + promptGuidance: [], + inputSchema: { type: "object" }, + shadowing: "allow", + }, + { + caplet: "github", + toolName: "caplets__github", + title: "Remote GitHub", + description: "Upstream GitHub.", + promptGuidance: [], + inputSchema: { type: "object" }, + shadowing: "forbid", + }, + { + caplet: "vps__browser", + toolName: "caplets__vps__browser", + title: "Remote Browser", + description: "Namespaced upstream browser.", + promptGuidance: [], + inputSchema: { type: "object" }, + shadowing: "namespace", + }, + ], + execute: vi.fn(), + reload: vi.fn(), + onToolsChanged: vi.fn(), + close: vi.fn(), + } as unknown as NativeCapletsService; + + const projection = await buildNativeAttachProjection(service); + + expect( + projection.manifest.caplets.map((caplet) => ({ + name: caplet.name, + capletId: caplet.capletId, + shadowing: caplet.shadowing, + })), + ).toEqual([ + { name: "filesystem", capletId: "filesystem", shadowing: "allow" }, + { name: "github", capletId: "github", shadowing: "forbid" }, + { name: "vps__browser", capletId: "vps__browser", shadowing: "namespace" }, + ]); + }); + + it("preserves native Code Mode caplets in attach manifests", async () => { + const service = { + listTools: () => [ + { + caplet: "filesystem", + toolName: "caplets__filesystem", + title: "Filesystem", + description: "Filesystem.", + promptGuidance: [], + inputSchema: { type: "object" }, + shadowing: "allow", + }, + { + caplet: "code_mode", + toolName: "caplets__code_mode", + title: "Code Mode", + description: "Code Mode.", + promptGuidance: [], + inputSchema: { type: "object" }, + codeModeRun: true, + codeModeCaplets: [ + { + id: "filesystem", + name: "Filesystem", + description: "Filesystem.", + shadowing: "allow", + }, + ], + }, + ], + execute: vi.fn(), + reload: vi.fn(), + onToolsChanged: vi.fn(), + close: vi.fn(), + } as unknown as NativeCapletsService; + + const projection = await buildNativeAttachProjection(service); + + expect(projection.manifest.caplets.map((caplet) => caplet.capletId)).toEqual(["filesystem"]); + expect(projection.manifest.codeModeCaplets).toEqual([ + expect.objectContaining({ + stableId: "native-code-mode:filesystem", + kind: "caplet", + name: "Filesystem", + capletId: "filesystem", + shadowing: "allow", + }), + ]); + }); + it("preserves direct resource metadata in attach manifests", async () => { const caplet = { server: "docs", diff --git a/packages/core/test/attach-cli.test.ts b/packages/core/test/attach-cli.test.ts index a3a24acc..738a558e 100644 --- a/packages/core/test/attach-cli.test.ts +++ b/packages/core/test/attach-cli.test.ts @@ -32,7 +32,12 @@ describe("caplets attach CLI", () => { expect(out.join("")).toContain("Start a remote-backed Caplets MCP server."); expect(out.join("")).toContain("Usage: caplets attach [options] [url]"); - expect(out.join("")).toContain("--transport "); + expect(out.join("")).not.toContain("--transport "); + expect(out.join("")).not.toContain("--host "); + expect(out.join("")).not.toContain("--port "); + expect(out.join("")).not.toContain("--path "); + expect(out.join("")).not.toContain("--allow-unauthenticated-http"); + expect(out.join("")).not.toContain("--trust-proxy"); expect(out.join("")).not.toContain("--remote-url "); expect(out.join("")).toContain("--workspace "); expect(out.join("")).toContain("--once"); @@ -73,6 +78,25 @@ describe("caplets attach CLI", () => { ).rejects.toThrow(/unknown option '--user'/u); }); + it.each([ + ["--transport", "http"], + ["--transport", "stdio"], + ["--host", "127.0.0.1"], + ["--port", "5387"], + ["--path", "/caplets"], + ["--allow-unauthenticated-http"], + ["--trust-proxy"], + ])("rejects attach HTTP serving flag %s", async (...flag: string[]) => { + await expect( + runCli(["attach", "https://caplets.example.com/caplets", ...flag], { + env: { CAPLETS_MODE: "remote" }, + attachServe: async () => undefined, + } as never), + ).rejects.toThrow( + /caplets attach is stdio-only.*caplets serve --transport http --upstream-url/u, + ); + }); + it("passes local overlay config paths into attach serving", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-attach-config-")); tempDirs.push(dir); diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index 35ed448e..9b9486cb 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -589,6 +589,31 @@ describe("cli init", () => { ]); }); + it("resolves HTTP serve with an upstream URL", async () => { + const served: unknown[] = []; + + delete process.env.CAPLETS_SERVER_URL; + delete process.env.CAPLETS_SERVER_USER; + delete process.env.CAPLETS_SERVER_PASSWORD; + + await runCli( + ["serve", "--transport", "http", "--upstream-url", "https://caplets.example.com/caplets"], + { + writeOut: () => {}, + serve: async (options) => { + served.push(options); + }, + }, + ); + + expect(served).toEqual([ + expect.objectContaining({ + transport: "http", + upstreamUrl: "https://caplets.example.com/caplets", + }), + ]); + }); + it("rejects HTTP-only serve options with stdio", async () => { await expect( runCli(["serve", "--transport", "stdio", "--port", "5387"], { writeErr: () => {} }), diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index 59356af3..380d6300 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ProjectBindingSessionManager } from "../src/cloud/presence"; +import { CAPLETS_ATTACH_SESSION_HEADER } from "../src/attach/api"; import type { CapletsError } from "../src/errors"; import { CloudAuthStore } from "../src/cloud-auth/store"; import { CapletsEngine } from "../src/engine"; @@ -183,6 +184,199 @@ function deferred() { } describe("RemoteNativeCapletsService", () => { + it("creates an attach session before fetching a session-aware manifest", async () => { + const requests: Array<{ url: string; method: string; headers: Headers; body?: unknown }> = []; + const fetchStub: typeof fetch = vi.fn(async (input, init) => { + const url = String(input); + const request = { + url, + method: init?.method ?? "GET", + headers: new Headers(init?.headers), + ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}), + }; + requests.push(request); + if (url.endsWith("/sessions")) { + return Response.json({ sessionId: "attach_session_123" }, { status: 201 }); + } + if (url.endsWith("/manifest")) { + return Response.json(attachManifest("rev-1", "export-1")); + } + if (url.endsWith("/invoke")) { + return Response.json({ ok: true, data: { invoked: true } }); + } + return Response.json({ ok: true }); + }); + const client = createSdkRemoteCapletsClient({ + url: new URL("https://caplets.example.com/v1/attach"), + requestInit: {}, + fetch: fetchStub, + auth: { enabled: false, user: "caplets" }, + pollIntervalMs: 60_000, + attachSessionMetadata: { + projectRoot: "/repo", + projectConfigPath: "/repo/.caplets/config.json", + }, + }); + + await client.listTools(); + await expect(client.callTool("remote", {})).resolves.toEqual({ invoked: true }); + await client.close(); + + expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([ + "POST https://caplets.example.com/v1/attach/sessions", + "GET https://caplets.example.com/v1/attach/manifest", + "POST https://caplets.example.com/v1/attach/invoke", + "DELETE https://caplets.example.com/v1/attach/sessions/attach_session_123", + ]); + expect(requests[0]?.body).toEqual({ + projectRoot: "/repo", + projectConfigPath: "/repo/.caplets/config.json", + }); + expect(requests[1]?.headers.get(CAPLETS_ATTACH_SESSION_HEADER)).toBe("attach_session_123"); + expect(requests[2]?.headers.get(CAPLETS_ATTACH_SESSION_HEADER)).toBe("attach_session_123"); + }); + + it("retries attach session discovery after an unsupported cooldown", async () => { + let now = new Date("2026-06-23T00:00:00.000Z").getTime(); + const dateNow = vi.spyOn(Date, "now").mockImplementation(() => now); + try { + let sessionsSupported = false; + const requests: Array<{ url: string; method: string; headers: Headers }> = []; + const fetchStub: typeof fetch = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers); + requests.push({ url, method, headers }); + if (url.endsWith("/sessions") && method === "POST") { + return sessionsSupported + ? Response.json({ sessionId: "attach_session_123" }, { status: 201 }) + : Response.json({ ok: false }, { status: 404 }); + } + if (url.endsWith("/manifest")) { + return Response.json(attachManifest("rev-1", "export-1")); + } + return Response.json({ ok: true }); + }); + const client = createSdkRemoteCapletsClient({ + url: new URL("https://caplets.example.com/v1/attach"), + requestInit: {}, + fetch: fetchStub, + auth: { enabled: false, user: "caplets" }, + pollIntervalMs: 60_000, + attachSessionMetadata: { projectRoot: "/repo" }, + }); + + await client.listTools(); + await client.listTools(); + sessionsSupported = true; + now += 60_000; + await client.listTools(); + await client.close(); + + const sessionRequests = requests.filter( + (request) => request.url.endsWith("/sessions") && request.method === "POST", + ); + const manifestRequests = requests.filter((request) => request.url.endsWith("/manifest")); + expect(sessionRequests).toHaveLength(2); + expect( + manifestRequests.map((request) => request.headers.get(CAPLETS_ATTACH_SESSION_HEADER)), + ).toEqual([null, null, "attach_session_123"]); + expect(requests.map((request) => `${request.method} ${request.url}`)).toContain( + "DELETE https://caplets.example.com/v1/attach/sessions/attach_session_123", + ); + } finally { + dateNow.mockRestore(); + } + }); + + it("recreates attach sessions when the remote forgets the previous session", async () => { + const requests: Array<{ url: string; method: string; headers: Headers }> = []; + const fetchStub: typeof fetch = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers); + requests.push({ url, method, headers }); + if (url.endsWith("/sessions")) { + const count = requests.filter((request) => request.url.endsWith("/sessions")).length; + return Response.json({ sessionId: `attach_session_${count}` }, { status: 201 }); + } + if ( + url.endsWith("/manifest") && + headers.get(CAPLETS_ATTACH_SESSION_HEADER) === "attach_session_1" + ) { + return Response.json( + { + ok: false, + error: { code: "REQUEST_INVALID", message: "Attach session was not found." }, + }, + { status: 400 }, + ); + } + if (url.endsWith("/manifest")) { + return Response.json(attachManifest("rev-1", "export-1")); + } + return Response.json({ ok: true }); + }); + const client = createSdkRemoteCapletsClient({ + url: new URL("https://caplets.example.com/v1/attach"), + requestInit: {}, + fetch: fetchStub, + auth: { enabled: false, user: "caplets" }, + pollIntervalMs: 60_000, + attachSessionMetadata: { projectRoot: "/repo" }, + }); + + await client.listTools(); + await client.close(); + + expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([ + "POST https://caplets.example.com/v1/attach/sessions", + "GET https://caplets.example.com/v1/attach/manifest", + "POST https://caplets.example.com/v1/attach/sessions", + "GET https://caplets.example.com/v1/attach/manifest", + "DELETE https://caplets.example.com/v1/attach/sessions/attach_session_2", + ]); + expect(requests[1]?.headers.get(CAPLETS_ATTACH_SESSION_HEADER)).toBe("attach_session_1"); + expect(requests[3]?.headers.get(CAPLETS_ATTACH_SESSION_HEADER)).toBe("attach_session_2"); + }); + + it("closes attach sessions that finish creating during client shutdown", async () => { + const sessionsStarted = deferred(); + const requests: Array<{ url: string; method: string }> = []; + const fetchStub: typeof fetch = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + requests.push({ url, method }); + if (url.endsWith("/sessions") && method === "POST") { + return await sessionsStarted.promise; + } + return Response.json({ ok: true }); + }); + const client = createSdkRemoteCapletsClient({ + url: new URL("https://caplets.example.com/v1/attach"), + requestInit: {}, + fetch: fetchStub, + auth: { enabled: false, user: "caplets" }, + pollIntervalMs: 60_000, + attachSessionMetadata: { projectRoot: "/repo" }, + }); + + const listTools = client.listTools(); + await vi.waitFor(() => + expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([ + "POST https://caplets.example.com/v1/attach/sessions", + ]), + ); + const close = client.close(); + sessionsStarted.resolve(Response.json({ sessionId: "attach_session_123" }, { status: 201 })); + await close; + await expect(listTools).rejects.toThrow(); + + expect(requests.map((request) => `${request.method} ${request.url}`)).toContain( + "DELETE https://caplets.example.com/v1/attach/sessions/attach_session_123", + ); + }); + it("refetches and retries once when attach invoke reports a stale manifest", async () => { const requests: Array<{ url: string; body?: unknown }> = []; const fetchStub: typeof fetch = vi.fn(async (input, init) => { @@ -3387,6 +3581,181 @@ describe("createNativeCapletsService remote mode", () => { ); }); + it("starts and tears down upstream Project Binding for self-hosted remote sessions", 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", syncState: "pending" }, + sessionId: "session_1", + }, + { status: 201 }, + ); + } + 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({ + mcpServers: { + local: { name: "Local", description: "Local Caplet.", command: process.execPath }, + }, + }); + dirs.push(dir); + const projectRoot = dirname(projectConfigPath); + + const service = createNativeCapletsService({ + mode: "remote", + remote: { + url: "http://127.0.0.1:5387", + fetch, + }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + projectRoot, + }); + + await vi.waitFor(() => + expect(fetch).toHaveBeenCalledWith( + new URL("http://127.0.0.1:5387/v1/attach/project-bindings/sessions"), + expect.objectContaining({ method: "POST" }), + ), + ); + await service.close(); + + const bodies = fetch.mock.calls + .map(([, init]) => init?.body) + .filter((body): body is string => typeof body === "string") + .map( + (body) => + JSON.parse(body) as { + projectRoot?: string; + allowedCapletIds?: string[]; + sessionId?: string; + }, + ); + expect(bodies[0]).toMatchObject({ + projectRoot, + allowedCapletIds: ["local", "code_mode"], + }); + expect(fetch).toHaveBeenCalledWith( + new URL("http://127.0.0.1:5387/v1/attach/project-bindings/binding_1/session"), + expect.objectContaining({ method: "DELETE" }), + ); + }); + + it("keeps stacked remote tools available when upstream Project Binding is unavailable", async () => { + const fixture = client([{ name: "remote", title: "Remote", description: "Remote Caplet." }]); + const fetch = vi.fn(async () => 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(projectConfigPath), + writeErr, + }); + + await vi.waitFor(() => + expect(writeErr).toHaveBeenCalledWith( + "Could not start upstream Project Binding: Project Binding request failed (404).\n", + ), + ); + await service.reload(); + + expect(configuredCapletIds(service.listTools())).toContain("remote"); + await service.close(); + }); + + it("retries self-hosted Project Binding registration after an initial failure", async () => { + const fixture = client([{ name: "remote", title: "Remote", description: "Remote Caplet." }]); + let bindingAvailable = false; + 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" + ) { + if (!bindingAvailable) { + return new Response("temporarily unavailable", { status: 503 }); + } + return Response.json( + { + binding: { bindingId: "binding_1", state: "attaching", syncState: "pending" }, + sessionId: "session_1", + }, + { status: 201 }, + ); + } + 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(projectConfigPath), + writeErr, + }); + + await vi.waitFor(() => + expect(writeErr).toHaveBeenCalledWith( + "Could not start upstream Project Binding: Project Binding request failed (503).\n", + ), + ); + bindingAvailable = true; + await service.reload(); + await vi.waitFor(() => + expect( + fetch.mock.calls.filter( + ([input, init]) => + new URL(input.toString()).pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST", + ), + ).toHaveLength(2), + ); + await service.close(); + + expect(fetch).toHaveBeenCalledWith( + new URL("http://127.0.0.1:5387/v1/attach/project-bindings/binding_1/session"), + expect.objectContaining({ method: "DELETE" }), + ); + }); + it("updates local presence after local overlay reload changes the Caplet set", async () => { const fixture = client(); const fetch = vi.fn( diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index c63537d1..de7830cc 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -1,12 +1,13 @@ 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 { CapletsError } from "../src/errors"; import { RemoteAuthFlowStore } from "../src/remote-control/auth-flow"; import { RemoteServerCredentialStore } from "../src/remote/server-credential-store"; import { createHttpServeApp } from "../src/serve/http"; +import { CAPLETS_ATTACH_SESSION_HEADER, type AttachManifest } from "../src/attach/api"; import type { HttpServeOptions } from "../src/serve/options"; const dirs: string[] = []; @@ -679,27 +680,36 @@ describe("createHttpServeApp", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ projectRoot: "/repo" }), }); - expect(session.status).toBe(201); - const created = (await session.json()) as { - binding: { bindingId: string }; - sessionId: string; - }; - expect(created.binding.bindingId).toEqual(expect.any(String)); - expect(created.sessionId).toEqual(expect.any(String)); + expect(session.status).toBe(501); + await expect(session.json()).resolves.toMatchObject({ + ok: false, + error: { + code: "UNSUPPORTED_CAPABILITY", + message: "Self-hosted Project Binding sessions are not implemented by this runtime.", + }, + }); const heartbeat = await app.request( - `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/heartbeat`, + "http://127.0.0.1:5387/v1/attach/project-bindings/binding_123/heartbeat", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }, ); - expect(heartbeat.status).toBe(200); - await expect(heartbeat.json()).resolves.toMatchObject({ ok: true }); + expect(heartbeat.status).toBe(501); + await expect(heartbeat.json()).resolves.toMatchObject({ + ok: false, + error: { code: "UNSUPPORTED_CAPABILITY" }, + binding: { bindingId: "binding_123", state: "not_attached" }, + }); const ended = await app.request( - `http://127.0.0.1:5387/v1/attach/project-bindings/${created.binding.bindingId}/session`, + "http://127.0.0.1:5387/v1/attach/project-bindings/binding_123/session", { method: "DELETE", headers: { "content-type": "application/json" }, body: "{}" }, ); - expect(ended.status).toBe(200); - await expect(ended.json()).resolves.toMatchObject({ ok: true }); + expect(ended.status).toBe(501); + await expect(ended.json()).resolves.toMatchObject({ + ok: false, + error: { code: "UNSUPPORTED_CAPABILITY" }, + binding: { bindingId: "binding_123", state: "not_attached" }, + }); await engine.close(); }); @@ -1094,6 +1104,307 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("only advertises attach sessions when the session route is mounted", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} }); + + const discovery = (await (await app.request("http://127.0.0.1:5387/v1")).json()) as { + links: Record; + }; + + expect(discovery.links).toMatchObject({ + attachManifest: "/v1/attach/manifest", + attachEvents: "/v1/attach/events", + attachInvoke: "/v1/attach/invoke", + }); + expect(discovery.links).not.toHaveProperty("attachSessions"); + + await app.closeCapletsSessions(); + await engine.close(); + }); + + it("routes attach manifest, invoke, and events through an attach session", async () => { + const { engine } = testEngine(); + const projectRoot = tempDir("caplets-attach-session-project-"); + const projectConfigPath = join(projectRoot, ".caplets", "config.json"); + const closed: string[] = []; + let eventListener: (() => void) | undefined; + const manifest = attachManifest("session-rev", "session-tool"); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + attachSessionFactory: (metadata) => ({ + manifest: async () => ({ + ...manifest, + diagnostics: [ + { + code: "SESSION_METADATA", + message: JSON.stringify(metadata), + }, + ], + }), + invoke: async (request) => ({ invoked: request.exportId }), + onManifestChanged: (listener) => { + eventListener = listener; + return () => { + eventListener = undefined; + }; + }, + close: async () => { + closed.push(metadata.projectRoot ?? ""); + }, + }), + }); + + const created = await app.request("http://127.0.0.1:5387/v1/attach/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + projectRoot, + projectConfigPath, + }), + }); + expect(created.status).toBe(201); + const body = (await created.json()) as { sessionId: string }; + + const sessionHeaders = { [CAPLETS_ATTACH_SESSION_HEADER]: body.sessionId }; + const manifestResponse = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: sessionHeaders, + }); + expect(manifestResponse.status).toBe(200); + await expect(manifestResponse.json()).resolves.toMatchObject({ + revision: "session-rev", + caplets: [expect.objectContaining({ capletId: "session-tool" })], + diagnostics: [ + expect.objectContaining({ + code: "SESSION_METADATA", + message: JSON.stringify({ + projectRoot, + projectConfigPath, + }), + }), + ], + }); + + const invoked = await app.request("http://127.0.0.1:5387/v1/attach/invoke", { + method: "POST", + headers: { ...sessionHeaders, "content-type": "application/json" }, + body: JSON.stringify({ + revision: "session-rev", + kind: "caplet", + exportId: "session-export", + input: {}, + }), + }); + expect(invoked.status).toBe(200); + await expect(invoked.json()).resolves.toEqual({ + ok: true, + data: { invoked: "session-export" }, + }); + + const events = await app.request("http://127.0.0.1:5387/v1/attach/events", { + headers: sessionHeaders, + }); + expect(events.status).toBe(200); + const reader = events.body!.getReader(); + await expect(reader.read()).resolves.toMatchObject({ done: false }); + eventListener?.(); + const changed = await reader.read(); + expect(new TextDecoder().decode(changed.value)).toContain("session-rev"); + await reader.cancel(); + + await app.closeCapletsSessions(); + expect(closed).toEqual([projectRoot]); + await engine.close(); + }); + + it("rejects attach session project config paths outside the project root", async () => { + const { engine } = testEngine(); + const projectRoot = tempDir("caplets-attach-session-project-"); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + attachSessionFactory: () => { + throw new Error("session factory should not run"); + }, + }); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot, projectConfigPath: "/tmp/outside-config.json" }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { + code: "REQUEST_INVALID", + message: "projectConfigPath must be /.caplets/config.json.", + }, + }); + + await app.closeCapletsSessions(); + await engine.close(); + }); + + it("rejects attach session project roots that are not directories", async () => { + const { engine } = testEngine(); + const projectRoot = tempDir("caplets-attach-session-project-"); + const filePath = join(projectRoot, "not-a-directory"); + writeFileSync(filePath, "content", "utf8"); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + attachSessionFactory: () => { + throw new Error("session factory should not run"); + }, + }); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot: filePath }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { + code: "REQUEST_INVALID", + message: "projectRoot must be an existing directory.", + }, + }); + + await app.closeCapletsSessions(); + await engine.close(); + }); + + it("rejects attach session project config paths that are not the standard project path", async () => { + const { engine } = testEngine(); + const projectRoot = tempDir("caplets-attach-session-project-"); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + attachSessionFactory: () => { + throw new Error("session factory should not run"); + }, + }); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + projectRoot, + projectConfigPath: "other-config.json", + }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { + code: "REQUEST_INVALID", + message: "projectConfigPath must be /.caplets/config.json.", + }, + }); + + await app.closeCapletsSessions(); + await engine.close(); + }); + + it("rejects attach session project context on non-loopback runtimes", async () => { + const { engine } = testEngine(); + const projectRoot = tempDir("caplets-attach-session-project-"); + const app = createHttpServeApp(httpOptions({ loopback: false }), engine, { + writeErr: () => {}, + attachSessionFactory: () => { + throw new Error("session factory should not run"); + }, + }); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { + code: "REQUEST_INVALID", + message: "Attach session project context is only accepted by loopback runtimes.", + }, + }); + + await app.closeCapletsSessions(); + await engine.close(); + }); + + it("returns structured errors for unknown attach session headers", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + attachSessionFactory: () => { + throw new Error("session factory should not run"); + }, + }); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { [CAPLETS_ATTACH_SESSION_HEADER]: "missing-session" }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + ok: false, + error: { + code: "REQUEST_INVALID", + message: "Attach session was not found.", + }, + }); + + await app.closeCapletsSessions(); + await engine.close(); + }); + + it("keeps attach sessions active while events are connected", async () => { + vi.useFakeTimers(); + const { engine } = testEngine(); + const manifest = attachManifest("session-rev", "session-tool"); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + attachSessionFactory: () => ({ + manifest: async () => manifest, + invoke: async () => ({ ok: true }), + onManifestChanged: () => () => undefined, + close: async () => undefined, + }), + }); + try { + const created = await app.request("http://127.0.0.1:5387/v1/attach/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + const body = (await created.json()) as { sessionId: string }; + const sessionHeaders = { [CAPLETS_ATTACH_SESSION_HEADER]: body.sessionId }; + const events = await app.request("http://127.0.0.1:5387/v1/attach/events", { + headers: sessionHeaders, + }); + const reader = events.body!.getReader(); + await reader.read(); + + await vi.advanceTimersByTimeAsync(11 * 60_000); + + const manifestResponse = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: sessionHeaders, + }); + expect(manifestResponse.status).toBe(200); + await reader.cancel(); + } finally { + vi.useRealTimers(); + await app.closeCapletsSessions(); + await engine.close(); + } + }); + it("invokes exported attach entries by revision-scoped export ID", async () => { const { engine } = testEngine({ options: { exposure: "progressive" }, @@ -1506,6 +1817,35 @@ function httpOptions(overrides: Partial = {}): HttpServeOption }; } +function attachManifest(revision: string, capletId: string): AttachManifest { + return { + version: 1, + revision, + generatedAt: "2026-06-23T00:00:00.000Z", + caplets: [ + { + stableId: `progressive:${capletId}`, + exportId: "session-export", + kind: "caplet", + name: capletId, + title: capletId, + description: "Session tool.", + inputSchema: { type: "object" }, + schemaHash: null, + capletId, + shadowing: "forbid", + }, + ], + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + completions: [], + codeModeCaplets: [], + diagnostics: [], + }; +} + function remoteCredentialStore(): RemoteServerCredentialStore { return new RemoteServerCredentialStore({ dir: tempDir("caplets-http-remote-credentials-") }); } diff --git a/packages/core/test/serve-options.test.ts b/packages/core/test/serve-options.test.ts index b7aababf..2febf7a9 100644 --- a/packages/core/test/serve-options.test.ts +++ b/packages/core/test/serve-options.test.ts @@ -183,6 +183,62 @@ describe("resolveServeOptions", () => { ); }); + it("resolves upstream URL for HTTP serving", () => { + expect( + resolveServeOptions( + { transport: "http", upstreamUrl: "https://caplets.example.com/caplets" }, + {}, + ), + ).toMatchObject({ + transport: "http", + upstreamUrl: "https://caplets.example.com/caplets", + }); + }); + + it("rejects upstream URL for stdio serving", () => { + expect(() => + resolveServeOptions( + { transport: "stdio", upstreamUrl: "https://caplets.example.com/caplets" }, + {}, + ), + ).toThrow(/--upstream-url is only valid with --transport http/u); + }); + + it("rejects self-referential upstream URLs", () => { + expect(() => + resolveServeOptions({ transport: "http", upstreamUrl: "http://127.0.0.1:5387/" }, {}), + ).toThrow(/must not point back to this runtime/u); + + expect(() => + resolveServeOptions({ transport: "http", upstreamUrl: "http://localhost:5387/" }, {}), + ).toThrow(/must not point back to this runtime/u); + + expect(() => + resolveServeOptions( + { transport: "http", host: "::1", upstreamUrl: "http://127.0.0.1:5387/" }, + {}, + ), + ).toThrow(/must not point back to this runtime/u); + + expect(() => + resolveServeOptions( + { transport: "http", host: "0.0.0.0", upstreamUrl: "http://127.0.0.1:5387/" }, + {}, + ), + ).toThrow(/must not point back to this runtime/u); + + expect(() => + resolveServeOptions({ transport: "http", host: "::", upstreamUrl: "http://[::1]:5387/" }, {}), + ).toThrow(/must not point back to this runtime/u); + + expect(() => + resolveServeOptions( + { transport: "http", upstreamUrl: "https://caplets.example.com/caplets" }, + { CAPLETS_SERVER_URL: "https://caplets.example.com/caplets/" }, + ), + ).toThrow(/must not point back to this runtime/u); + }); + it("defaults daemonized serve to HTTP", () => { expect(resolveDaemonHttpServeOptions({}, {})).toMatchObject({ transport: "http", From f63bd5117b80ab0a83af87a6de8dff0bb621498b Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Wed, 24 Jun 2026 05:05:44 -0400 Subject: [PATCH 2/3] fix(core): address stacked runtime review feedback --- ...-23-stacked-remote-runtime-requirements.md | 157 ---------------- ...23-002-feat-stacked-remote-runtime-plan.md | 5 +- packages/core/src/attach/api.ts | 77 ++++++-- packages/core/src/cli.ts | 2 - packages/core/src/native/options.ts | 22 ++- packages/core/src/native/remote.ts | 26 ++- packages/core/src/native/service.ts | 42 ++++- packages/core/src/serve/http.ts | 144 ++++++++++++--- packages/core/src/serve/index.ts | 97 ++++++++-- packages/core/test/attach-api.test.ts | 49 +++++ packages/core/test/native-remote.test.ts | 170 +++++++++++++++++- packages/core/test/serve-http.test.ts | 165 ++++++++++++++++- 12 files changed, 723 insertions(+), 233 deletions(-) delete mode 100644 docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md diff --git a/docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md b/docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md deleted file mode 100644 index 20dd5121..00000000 --- a/docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -date: 2026-06-23 -topic: stacked-remote-runtime ---- - -# Stacked Remote Runtime Requirements - -## Summary - -Add a stacked HTTP runtime shape where `caplets serve --transport http --upstream-url ` serves local Caplets together with an upstream Caplets host, while `caplets attach ` becomes a stdio-only session client that supplies project context to the runtime it connects to. - ---- - -## Problem Frame - -Codex MCP server startup does not reliably inherit the user's shell environment or pass expected environment variables through to server commands. That makes env-interpolated Caplets config fragile when Codex launches `caplets serve` or `caplets attach` directly. - -Caplets already has pieces that point away from env-dependent child startup: Remote Profiles keep remote credentials out of agent config, the Caplets Daemon models runtime launch as an install-time service contract, Remote Attach composes local and remote capabilities, Project Binding connects a local project root to a remote runtime, and Namespace Shadowing keeps local/upstream ID collisions predictable. - -The desired product shape is to move durable runtime responsibility into a local HTTP service that owns env, Vault, auth, logs, reloads, and health. Agent-facing `attach` should be a thin stdio session adapter rather than another long-lived server command with its own HTTP transport. - ---- - -## Key Decisions - -- **Stacked runtime is a `serve` role.** `caplets serve --transport http --upstream-url ` starts the composed runtime because it publishes an HTTP service for clients. -- **Remote session adapter is an `attach` role.** `caplets attach ` stays the command installed into MCP clients and always speaks stdio to the launching client. -- **Use `--upstream-url`, not `--remote-url`, on `serve`.** The flag names the server behind the local runtime rather than implying this process itself is the remote target. -- **Project context is session-scoped.** The long-running HTTP runtime must not treat its startup CWD as the active project for every client. -- **`caplets attach` uses CWD for project context.** Native integrations may pass an explicit project root through their API; the CLI attach adapter should default to its launch CWD. -- **Existing shadowing semantics stay authoritative.** Upstream Caplet `shadowing` policy decides collision behavior: `forbid` suppresses local same-ID Caplets, `allow` permits local bare-ID use, and `namespace` qualifies both sides. -- **Upstream Project Binding is automatic.** When a session supplies a project root and the upstream supports Project Binding, the stacked runtime should attempt binding without another flag. - ---- - -## Actors - -- A1. **Agent user:** Wants Codex and native integrations to see stable Caplets without hand-wiring env secrets into MCP config. -- A2. **MCP client:** Launches `caplets attach ` and communicates over stdio. -- A3. **Local stacked runtime:** Runs `caplets serve --transport http --upstream-url ` and composes local, project, and upstream Caplets. -- A4. **Upstream Caplets host:** Publishes remote capabilities, shadowing policy, Remote Profile requirements, and Project Binding support. -- A5. **Native integration:** Starts a Caplets session with an explicit project root when its host API exposes one. - ---- - -## Requirements - -**Command model** - -- R1. `caplets serve --transport http --upstream-url ` must start an HTTP runtime that composes local Caplets with the trusted upstream runtime at ``. -- R2. `caplets attach ` must expose stdio only to the launching MCP client and must not support HTTP serving options as the public attach contract. -- R3. `caplets attach ` must use its launch CWD as the session project root unless an explicit project root override is provided for tests or native integration plumbing. -- R4. Native integrations must be able to supply project root explicitly and fall back to CWD only when no host-provided root exists. - -**Composition and shadowing** - -- R5. The stacked runtime must expose a resolved surface containing local user/global Caplets, session project Caplets, and upstream Caplets. -- R6. The stacked runtime must apply existing upstream-authored shadowing semantics for every local/upstream base-ID collision. -- R7. Namespace-qualified IDs must use durable source identity and configured namespace aliases consistently across local and upstream sources. -- R8. Stacked composition must fail closed with diagnostics when a namespace collision cannot be resolved safely. - -**Project Binding** - -- R9. When a session has a project root and the upstream advertises Project Binding support, the stacked runtime must attempt upstream Project Binding automatically. -- R10. If upstream Project Binding fails, local project Caplets and non-project upstream Caplets should remain available when safe, with a degraded diagnostic that points to recovery. -- R11. Project context must be per session so one long-running local runtime can serve clients from different repositories without project leakage. - -**Credentials and env ownership** - -- R12. Agent configs must continue to contain stable non-secret selectors such as `caplets attach `, not bearer tokens, passwords, or copied env secrets. -- R13. The stacked runtime must resolve upstream credentials from Remote Profiles and local config secrets from Caplets-owned runtime mechanisms. -- R14. Upstream credentials, remote Vault values, and local env-derived config must not be mirrored into agent-visible config or session metadata. - -**Diagnostics and compatibility** - -- R15. Attach help and errors must guide users from removed HTTP attach usage toward `caplets serve --transport http --upstream-url `. -- R16. Serve help, docs, and generated setup guidance must teach `--upstream-url` for stacked runtimes. -- R17. `caplets doctor` should distinguish local runtime health, upstream Remote Profile health, and upstream Project Binding health. - ---- - -## Key Flows - -- F1. **Start stacked runtime** - - **Trigger:** User or daemon starts `caplets serve --transport http --upstream-url `. - - **Actors:** A1, A3, A4 - - **Steps:** The runtime resolves local config, resolves the upstream through Remote Profiles, starts HTTP service endpoints, and prepares to compose per-session project context. - - **Outcome:** Clients can attach to one local HTTP runtime that represents local plus upstream capabilities. - -- F2. **Attach from a project** - - **Trigger:** MCP client launches `caplets attach ` from a repository root. - - **Actors:** A2, A3 - - **Steps:** The attach adapter sends stdio to the client, supplies its CWD as session project root, and consumes the resolved runtime surface from the local runtime. - - **Outcome:** The client sees the composed Caplets surface for that repository without Codex launching env-sensitive server commands. - -- F3. **Compose a collision** - - **Trigger:** Local or project Caplet ID matches an upstream Caplet ID. - - **Actors:** A3, A4 - - **Steps:** The runtime reads the upstream Caplet shadowing policy and applies `forbid`, `allow`, or `namespace` behavior. - - **Outcome:** The visible Caplet IDs match current shadowing rules and never silently choose an ambiguous namespace collision. - -- F4. **Bind upstream project context** - - **Trigger:** A session supplies project root and upstream Project Binding is available. - - **Actors:** A3, A4 - - **Steps:** The stacked runtime starts Project Binding automatically, syncs per existing safety rules, and attaches upstream project-bound capabilities when ready. - - **Outcome:** Remote project-bound Caplets can operate against the same project context while local project Caplets remain local. - ---- - -## Acceptance Examples - -- AE1. **Covers R1, R5, R6.** Given Hosted #1 publishes `filesystem` with `shadowing: allow`, `github` with `shadowing: forbid`, and `browser` with `shadowing: namespace`, when Hosted #2 runs `caplets serve --transport http --upstream-url ` with local Caplets of the same IDs, then `filesystem` is locally available, `github` routes to upstream, and `browser` is exposed only through qualified IDs. -- AE2. **Covers R2, R15.** Given a user runs `caplets attach --transport http`, then the command refuses HTTP attach serving and points to `caplets serve --transport http --upstream-url `. -- AE3. **Covers R3, R11.** Given two MCP clients launch `caplets attach ` from different repository roots, then the stacked runtime resolves project Caplets independently for each session. -- AE4. **Covers R9, R10.** Given upstream Project Binding is unavailable, when a session attaches with a project root, then local project Caplets and non-project upstream Caplets remain available and diagnostics report degraded Project Binding. -- AE5. **Covers R12, R13, R14.** Given an agent config launches `caplets attach `, then upstream credentials are resolved from Remote Profiles and no credential material is embedded in the agent config. - ---- - -## Scope Boundaries - -- The first version targets one `--upstream-url` per stacked runtime; arbitrary fan-in across many upstreams is deferred. -- This does not replace Namespace Shadowing semantics; it must reuse and preserve them. -- This does not make `caplets attach` a daemon installer or service manager. -- This does not require hosted Cloud for local stacked usage. -- This does not expose local shell env or Vault values to upstream hosts except through explicit, existing runtime contracts. - ---- - -## Dependencies / Assumptions - -- Remote Profiles remain the source of truth for self-hosted and Cloud upstream credentials. -- Existing attach and native service code can be reused or refactored so `serve --upstream-url` does not create a parallel composition engine. -- Project Binding safety rules from `docs/project-binding.md` remain in force for upstream sync. -- Namespace Shadowing requirements in `docs/brainstorms/2026-06-23-namespace-shadowing-policy-requirements.md` are either implemented first or implemented as a prerequisite slice. - ---- - -## Outstanding Questions - -### Deferred to Planning - -- What compatibility window should `caplets attach --transport stdio` receive before the flag is fully rejected? -- What exact session metadata shape should carry project root from stdio attach and native integrations to an HTTP stacked runtime? -- What cycle detection and maximum stack depth should protect `--upstream-url` chains? - ---- - -## Sources / Research - -- `STRATEGY.md` anchors this under the Remote runtime and Project Binding track. -- `CONCEPTS.md` defines Remote Attach, Remote Login, Remote Profile, Namespace Shadowing Policy, Caplets Daemon, and Vault boundaries. -- `packages/core/src/cli.ts` currently exposes `--transport`, HTTP bind flags, and `--remote-url` on `caplets attach`. -- `packages/core/src/attach/options.ts` currently resolves attach remote selection together with serve transport options and CWD-derived project root. -- `packages/core/src/attach/server.ts` currently lets remote-backed attach serve either stdio or HTTP. -- `packages/core/src/native/service.ts` already composes remote and local tools, applies namespace exposure, and uses local/project config paths for namespace identity. -- `docs/project-binding.md` defines current Project Binding attach behavior and recovery states. diff --git a/docs/plans/2026-06-23-002-feat-stacked-remote-runtime-plan.md b/docs/plans/2026-06-23-002-feat-stacked-remote-runtime-plan.md index e0cfcf79..c3c35b79 100644 --- a/docs/plans/2026-06-23-002-feat-stacked-remote-runtime-plan.md +++ b/docs/plans/2026-06-23-002-feat-stacked-remote-runtime-plan.md @@ -2,7 +2,6 @@ title: "feat: Add stacked remote runtime" type: feat date: 2026-06-23 -origin: docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md --- # feat: Add stacked remote runtime @@ -23,7 +22,7 @@ Codex can launch MCP commands without the user's expected shell environment, whi **Command contract** -- PR1. `caplets serve --transport http --upstream-url ` starts the stacked runtime described by the origin requirements. +- PR1. `caplets serve --transport http --upstream-url ` starts the stacked runtime described by these requirements. - PR2. `caplets attach ` is stdio-only for normal MCP use and no longer presents HTTP serving as an attach mode. - PR3. Attach and native sessions carry project root into the stacked runtime without relying on the runtime process CWD. @@ -177,7 +176,7 @@ The runtime owns composition and diagnostics. The attach adapter owns stdio brid ## Sources / Research -- Origin requirements: `docs/brainstorms/2026-06-23-stacked-remote-runtime-requirements.md`. +- Requirements and implementation plan: this document. - Product strategy: `STRATEGY.md`, especially the Remote runtime and Project Binding track. - Domain vocabulary: `CONCEPTS.md`. - Current attach command and serve command definitions: `packages/core/src/cli.ts`. diff --git a/packages/core/src/attach/api.ts b/packages/core/src/attach/api.ts index 31069194..924be973 100644 --- a/packages/core/src/attach/api.ts +++ b/packages/core/src/attach/api.ts @@ -184,22 +184,8 @@ export async function buildNativeAttachProjection( ): Promise { const tools = service.listTools(); const partial = sortAttachProjectionInput({ - caplets: tools - .filter((tool) => tool.codeModeRun !== true) - .map((tool) => ({ - stableId: `native:${tool.caplet}`, - kind: "caplet" as const, - name: tool.caplet, - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema, - outputSchema: tool.outputSchema, - annotations: tool.annotations, - schemaHash: schemaHash(tool.inputSchema ?? null), - capletId: tool.caplet, - shadowing: tool.shadowing ?? "forbid", - })), - tools: [], + caplets: nativeProgressiveCaplets(tools), + tools: nativeDirectTools(tools), resources: [], resourceTemplates: [], prompts: [], @@ -220,6 +206,60 @@ export async function buildNativeAttachProjection( }; } +function nativeProgressiveCaplets( + tools: ReturnType, +): Array> { + return tools + .filter((tool) => tool.codeModeRun !== true && !nativeDirectToolOperation(tool)) + .map((tool) => ({ + stableId: `native:${tool.caplet}`, + kind: "caplet" as const, + name: tool.caplet, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: tool.annotations, + schemaHash: schemaHash(tool.inputSchema ?? null), + capletId: tool.caplet, + shadowing: tool.shadowing ?? "forbid", + })); +} + +function nativeDirectTools( + tools: ReturnType, +): Array> { + return tools.flatMap((tool) => { + const operation = nativeDirectToolOperation(tool); + if (!operation || tool.codeModeRun === true || !tool.sourceCaplet) return []; + return [ + { + stableId: `native-tool:${tool.caplet}`, + kind: "tool" as const, + name: tool.caplet, + downstreamName: operation, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: tool.annotations, + schemaHash: schemaHash({ input: tool.inputSchema, output: tool.outputSchema }), + capletId: tool.sourceCaplet, + shadowing: tool.shadowing ?? "forbid", + }, + ]; + }); +} + +function nativeDirectToolOperation( + tool: ReturnType[number], +): string | undefined { + if (!tool.sourceCaplet || !tool.caplet.startsWith(`${tool.sourceCaplet}__`)) { + return undefined; + } + return tool.caplet.slice(tool.sourceCaplet.length + 2); +} + function nativeCodeModeCaplets( tools: ReturnType, ): Array> { @@ -250,9 +290,12 @@ export async function invokeNativeAttachExport( throw new CapletsError("ATTACH_EXPORT_NOT_FOUND", "Attach export was not found."); } if (route.kind !== "caplet") { + if (route.kind === "tool") { + return await service.execute(`${route.capletId}__${route.downstreamName}`, request.input); + } throw new CapletsError( "REQUEST_INVALID", - "Native attach sessions only support Caplet exports.", + "Native attach sessions only support Caplet and tool exports.", ); } return await service.execute(route.capletId, request.input); diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 7cfd5773..b371df3a 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -299,8 +299,6 @@ function rejectAttachHttpServeFlags(options: { ); } -export const rejectAttachHttpServeFlagsForTests = rejectAttachHttpServeFlags; - function hiddenOption(flags: string, description: string): Option { return new Option(flags, description).hideHelp(); } diff --git a/packages/core/src/native/options.ts b/packages/core/src/native/options.ts index 33d7cb2a..5e853dfa 100644 --- a/packages/core/src/native/options.ts +++ b/packages/core/src/native/options.ts @@ -16,6 +16,7 @@ export type NativeRemoteCapletsOptions = { url?: string; workspace?: string; fetch?: typeof fetch; + requestHeaders?: Record; pollIntervalMs?: number; cloud?: NativeCloudPresenceInput; }; @@ -98,22 +99,35 @@ export function resolveNativeCapletsServiceOptions( ); const cloud = resolveNativeCloudPresence(input.remote?.cloud, env); + const requestInit = + mode.mode === "cloud" && cloud + ? { headers: { Authorization: `Bearer ${cloud.accessToken}` } } + : server.requestInit; return { mode: mode.mode, remote: { url: server.attachUrl, auth: nativeAuthFromRemoteAuth(server.auth), pollIntervalMs: parsePollInterval(input.remote?.pollIntervalMs), - requestInit: - mode.mode === "cloud" && cloud - ? { headers: { Authorization: `Bearer ${cloud.accessToken}` } } - : server.requestInit, + requestInit: withRequestHeaders(requestInit, input.remote?.requestHeaders), ...(cloud ? { cloud } : {}), ...(server.fetch ? { fetch: server.fetch } : {}), }, }; } +function withRequestHeaders( + requestInit: RequestInit, + requestHeaders: Record | undefined, +): RequestInit { + if (!requestHeaders) return requestInit; + const headers = new Headers(requestInit.headers); + for (const [name, value] of Object.entries(requestHeaders)) { + headers.set(name, value); + } + return { ...requestInit, headers }; +} + function resolveNativeHostedCloudRemote( url: string, workspace: string | undefined, diff --git a/packages/core/src/native/remote.ts b/packages/core/src/native/remote.ts index 44c0110b..f54d9898 100644 --- a/packages/core/src/native/remote.ts +++ b/packages/core/src/native/remote.ts @@ -170,12 +170,12 @@ export function createSdkRemoteCapletsClient( if ( !options.attachSessionMetadata || attachSessionsUnsupportedUntil > Date.now() || - !attachSessionId || + !sessionId || !isAttachSessionNotFound(error) ) { throw error; } - attachSessionId = undefined; + if (attachSessionId === sessionId) attachSessionId = undefined; const nextSessionId = await ensureAttachSession(runtimeOptions); return await operation(runtimeOptions, nextSessionId); } @@ -201,10 +201,12 @@ export function createSdkRemoteCapletsClient( try { const runtimeOptions = await resolveRuntimeOptions(); if (closed || eventsAbort || listeners.size === 0) return; + const sessionId = await ensureAttachSession(runtimeOptions); + if (closed || eventsAbort || listeners.size === 0) return; eventsAbort = startAttachEvents( runtimeOptions.url, runtimeOptions.requestInit, - await ensureAttachSession(runtimeOptions), + sessionId, fetchFor(runtimeOptions), listeners, (closedAbort, retry) => { @@ -615,6 +617,15 @@ async function createAttachSession( }); if (!response.ok) { if (response.status === 404) return undefined; + let payload: unknown; + try { + payload = (await response.json()) as unknown; + } catch { + payload = undefined; + } + if (response.status === 400 && isAttachSessionProjectContextRejected(payload)) { + return undefined; + } throw new CapletsError( "SERVER_UNAVAILABLE", `Caplets attach session returned HTTP ${response.status}.`, @@ -1040,6 +1051,15 @@ function isAttachSessionNotFound(error: unknown): boolean { ); } +function isAttachSessionProjectContextRejected(payload: unknown): boolean { + const error = isPlainObject(payload) && isPlainObject(payload.error) ? payload.error : undefined; + return ( + error?.code === "REQUEST_INVALID" && + typeof error.message === "string" && + /\bproject context is only accepted by loopback runtimes\b/iu.test(error.message) + ); +} + function startAttachEvents( attachUrl: URL, requestInit: RequestInit | undefined, diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index 1ddb9152..5955677b 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -1,4 +1,5 @@ import { resolve as resolvePath } from "node:path"; +import { isLoopbackHost } from "../server/options"; import type { NativeCapletsServiceResolutionInput } from "./options"; import { resolveNativeCapletsServiceOptions, @@ -683,7 +684,9 @@ function createRemoteClient( if (options.remoteClientFactory) { return options.remoteClientFactory(remoteOptions); } - const attachSessionMetadata = attachSessionMetadataForOptions(options); + const attachSessionMetadata = isLoopbackRemote(remoteOptions) + ? attachSessionMetadataForOptions(options) + : undefined; const sdkOptions: SdkRemoteCapletsClientOptions = { ...remoteOptions, authKind, @@ -704,6 +707,10 @@ function attachSessionMetadataForOptions( }; } +function isLoopbackRemote(remoteOptions: ResolvedNativeRemoteOptions): boolean { + return remoteOptions.url.protocol === "http:" && isLoopbackHost(remoteOptions.url.hostname); +} + class ProfileBackedNativeCapletsService implements NativeCapletsService { private readonly local: NativeCapletsService; private readonly listeners = new Set(); @@ -1430,7 +1437,7 @@ function createProjectBindingSessionManager( }, }); } - if (!options.projectRoot) { + if (!options.projectRoot || !isLoopbackRemote(remoteOptions)) { return undefined; } return new RemoteProjectBindingSessionManager({ @@ -1450,6 +1457,7 @@ class RemoteProjectBindingSessionManager implements NativeProjectBindingManager private allowedCapletIds: string[]; private heartbeatTimer: ReturnType | undefined; private startPromise: Promise | undefined; + private unsupported = false; constructor( private readonly options: { @@ -1466,10 +1474,14 @@ class RemoteProjectBindingSessionManager implements NativeProjectBindingManager } async start(): Promise { + if (this.unsupported) return; if (!this.startPromise) { const start = this.register(); this.startPromise = start; - void start.catch(() => { + void start.catch((error) => { + if (isUnsupportedProjectBinding(error)) { + this.unsupported = true; + } if (this.startPromise === start) { this.startPromise = undefined; } @@ -1497,6 +1509,7 @@ class RemoteProjectBindingSessionManager implements NativeProjectBindingManager async updateAllowedCapletIds(allowedCapletIds: string[]): Promise { this.allowedCapletIds = [...allowedCapletIds]; + await this.startPromise?.catch(() => undefined); if (!this.bindingId || !this.sessionId) return; await this.heartbeat().catch(() => undefined); } @@ -1526,7 +1539,7 @@ class RemoteProjectBindingSessionManager implements NativeProjectBindingManager this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => { void this.heartbeat().catch((error) => { - this.stopHeartbeat(); + this.disconnect(); this.options.writeErr?.( `Remote Project Binding heartbeat failed: ${errorMessage(error)}\n`, ); @@ -1541,6 +1554,13 @@ class RemoteProjectBindingSessionManager implements NativeProjectBindingManager this.heartbeatTimer = undefined; } + private disconnect(): void { + this.stopHeartbeat(); + this.bindingId = undefined; + this.sessionId = undefined; + this.startPromise = undefined; + } + private async heartbeat(): Promise { if (!this.bindingId || !this.sessionId) return; await this.fetchJson(projectBindingUrl(this.options.attachUrl, this.bindingId, "heartbeat"), { @@ -1567,6 +1587,16 @@ class RemoteProjectBindingSessionManager implements NativeProjectBindingManager body: JSON.stringify(input.body), }); if (!response.ok) { + let payload: unknown; + try { + payload = (await response.json()) as unknown; + } catch { + payload = undefined; + } + const error = isRecord(payload) && isRecord(payload.error) ? payload.error : undefined; + if (error?.code === "UNSUPPORTED_CAPABILITY" && typeof error.message === "string") { + throw new CapletsError("UNSUPPORTED_CAPABILITY", error.message); + } throw new CapletsError( "SERVER_UNAVAILABLE", `Project Binding request failed (${response.status}).`, @@ -1576,6 +1606,10 @@ class RemoteProjectBindingSessionManager implements NativeProjectBindingManager } } +function isUnsupportedProjectBinding(error: unknown): boolean { + return isRecord(error) && error.code === "UNSUPPORTED_CAPABILITY"; +} + function projectBindingUrl(attachUrl: URL, ...segments: string[]): URL { const url = new URL(attachUrl); const base = url.pathname.replace(/\/+$/u, ""); diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index 969196f7..b9e21f87 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { existsSync, realpathSync, statSync } from "node:fs"; -import { dirname, isAbsolute, resolve } from "node:path"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; import { StreamableHTTPTransport } from "@hono/mcp"; import { serve, type ServerType } from "@hono/node-server"; import { Hono, type MiddlewareHandler } from "hono"; @@ -33,6 +33,7 @@ type HttpServeIo = { authFlowStore?: RemoteAuthFlowStore; sessionFactory?: HttpMcpSessionFactory; attachSessionFactory?: HttpAttachSessionFactory; + defaultAttachSessionFactory?: HttpAttachSessionFactory; exposeAttach?: boolean; remoteCredentialStore?: RemoteServerCredentialStore; }; @@ -44,6 +45,8 @@ type HttpMcpSession = { export type HttpMcpSessionFactory = () => HttpMcpSession | Promise; +export const CAPLETS_STACK_CHAIN_HEADER = "caplets-stack-chain"; + export type HttpAttachSession = { manifest(): Promise; invoke(request: AttachInvokeRequest): Promise; @@ -51,8 +54,13 @@ export type HttpAttachSession = { close(): Promise; }; +export type HttpAttachSessionContext = { + stackChain: string[]; +}; + export type HttpAttachSessionFactory = ( metadata: AttachSessionMetadata, + context: HttpAttachSessionContext, ) => HttpAttachSession | Promise; type HttpSession = { @@ -79,6 +87,7 @@ type AttachEventSource = { }; const ATTACH_SESSION_IDLE_TIMEOUT_MS = 10 * 60_000; +const ATTACH_SESSION_PRUNE_INTERVAL_MS = 60_000; export function createHttpServeApp( options: HttpServeOptions, @@ -88,9 +97,17 @@ export function createHttpServeApp( const app = new Hono() as CapletsHttpApp; const sessions = new Map(); const attachSessions = new Map(); + const defaultAttachSessions = new Map(); + const defaultAttachSessionPromises = new Map>(); const attachEventStreams = new Set(); + const attachSessionPruneTimer = setInterval( + () => pruneIdleAttachSessions(), + ATTACH_SESSION_PRUNE_INTERVAL_MS, + ); + attachSessionPruneTimer.unref?.(); const writeErr = io.writeErr ?? process.stderr.write.bind(process.stderr); const paths = servicePaths(options.path); + const stackIdentity = httpStackIdentity(options); const authFlowStore = io.authFlowStore ?? new RemoteAuthFlowStore(); const exposeAttach = io.exposeAttach ?? true; const exposeAttachSessions = exposeAttach && Boolean(io.attachSessionFactory); @@ -334,8 +351,9 @@ export function createHttpServeApp( const metadata = parseAttachSessionMetadata(parsed, { allowProjectContext: options.loopback, }); + const context = attachSessionContext(c.req.header(CAPLETS_STACK_CHAIN_HEADER)); const sessionId = randomUUID(); - const session = await io.attachSessionFactory!(metadata); + const session = await io.attachSessionFactory!(metadata, context); attachSessions.set(sessionId, { session, lastUsedAt: Date.now() }); pruneIdleAttachSessions(); return c.json({ sessionId }, 201); @@ -364,7 +382,12 @@ export function createHttpServeApp( app.get(paths.attachManifest, attachHostProtection, protectedRouteAuth, async (c) => { try { - const attachSession = attachSessionForRequest(c.req.header(CAPLETS_ATTACH_SESSION_HEADER)); + const attachSessionId = c.req.header(CAPLETS_ATTACH_SESSION_HEADER); + const attachSession = attachSessionId + ? attachSessionForRequest(attachSessionId) + : await fallbackAttachSession( + attachSessionContext(c.req.header(CAPLETS_STACK_CHAIN_HEADER)), + ); if (attachSession) return c.json(await attachSession.manifest()); const attachProjection = await buildAttachProjection(engine); return c.json(attachProjection.manifest); @@ -374,10 +397,14 @@ export function createHttpServeApp( } }); - app.get(paths.attachEvents, attachHostProtection, protectedRouteAuth, (c) => { + app.get(paths.attachEvents, attachHostProtection, protectedRouteAuth, async (c) => { try { const attachSessionId = c.req.header(CAPLETS_ATTACH_SESSION_HEADER); - const attachSession = attachSessionForRequest(attachSessionId); + const attachSession = attachSessionId + ? attachSessionForRequest(attachSessionId) + : await fallbackAttachSession( + attachSessionContext(c.req.header(CAPLETS_STACK_CHAIN_HEADER)), + ); return attachEventsResponse(attachEventSource(engine, attachSession), attachEventStreams, { onActivity: () => { if (attachSessionId) touchAttachSession(attachSessionId); @@ -392,7 +419,12 @@ export function createHttpServeApp( app.post(paths.attachInvoke, attachHostProtection, protectedRouteAuth, async (c) => { try { const request = await parseAttachInvokeRequest(c.req.json()); - const attachSession = attachSessionForRequest(c.req.header(CAPLETS_ATTACH_SESSION_HEADER)); + const attachSessionId = c.req.header(CAPLETS_ATTACH_SESSION_HEADER); + const attachSession = attachSessionId + ? attachSessionForRequest(attachSessionId) + : await fallbackAttachSession( + attachSessionContext(c.req.header(CAPLETS_STACK_CHAIN_HEADER)), + ); if (attachSession) { return c.json({ ok: true, data: await attachSession.invoke(request) }); } @@ -417,6 +449,39 @@ export function createHttpServeApp( return record.session; } + async function fallbackAttachSession( + context: HttpAttachSessionContext, + ): Promise { + if (!io.defaultAttachSessionFactory) return undefined; + const key = context.stackChain.join("\0"); + const existing = defaultAttachSessions.get(key); + if (existing) return existing; + let pending = defaultAttachSessionPromises.get(key); + if (!pending) { + pending = Promise.resolve(io.defaultAttachSessionFactory({}, context)).then( + (session) => { + defaultAttachSessions.set(key, session); + defaultAttachSessionPromises.delete(key); + return session; + }, + (error) => { + defaultAttachSessionPromises.delete(key); + throw error; + }, + ); + defaultAttachSessionPromises.set(key, pending); + } + return await pending; + } + + function attachSessionContext(header: string | undefined): HttpAttachSessionContext { + const incoming = stackChainFromHeader(header); + if (incoming.includes(stackIdentity)) { + throw new CapletsError("REQUEST_INVALID", "Stacked runtime upstream cycle detected."); + } + return { stackChain: [...incoming, stackIdentity] }; + } + function touchAttachSession(sessionId: string): void { const record = attachSessions.get(sessionId); if (record) record.lastUsedAt = Date.now(); @@ -515,6 +580,7 @@ export function createHttpServeApp( app.notFound((c) => c.json({ error: "not_found" }, 404)); app.closeCapletsSessions = async () => { + clearInterval(attachSessionPruneTimer); for (const stream of attachEventStreams) { stream.close(); } @@ -526,6 +592,9 @@ export function createHttpServeApp( sessions.clear(); await Promise.allSettled([...attachSessions.values()].map((record) => record.session.close())); attachSessions.clear(); + await Promise.allSettled([...defaultAttachSessions.values()].map((session) => session.close())); + defaultAttachSessions.clear(); + defaultAttachSessionPromises.clear(); }; if (options.warnUnauthenticatedNetwork) { @@ -638,6 +707,22 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function httpStackIdentity(options: HttpServeOptions): string { + const origin = options.publicOrigin ?? `http://${formatHost(options.host)}:${options.port}`; + const url = new URL(origin); + url.pathname = options.path; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function stackChainFromHeader(header: string | undefined): string[] { + return (header ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + type RemoteHostMetadata = { hostIdentity: string; audience: string; @@ -770,27 +855,34 @@ function canonicalProjectConfigPath( projectConfigPath: string | undefined, projectRoot: string | undefined, ): string | undefined { - if (!projectConfigPath) return undefined; if (!projectRoot) { + if (!projectConfigPath) return undefined; throw new CapletsError("REQUEST_INVALID", "projectConfigPath requires projectRoot."); } - const absoluteConfigPath = isAbsolute(projectConfigPath) - ? projectConfigPath - : resolve(projectRoot, projectConfigPath); - const candidate = existsSync(absoluteConfigPath) - ? realpathSync(absoluteConfigPath) - : resolve(absoluteConfigPath); const expectedProjectConfigPath = resolve(projectRoot, ".caplets", "config.json"); - const expected = existsSync(expectedProjectConfigPath) - ? realpathSync(expectedProjectConfigPath) - : expectedProjectConfigPath; - if (candidate === expected) { - return expected; + const absoluteConfigPath = + projectConfigPath === undefined + ? expectedProjectConfigPath + : isAbsolute(projectConfigPath) + ? projectConfigPath + : resolve(projectRoot, projectConfigPath); + if (resolve(absoluteConfigPath) !== expectedProjectConfigPath) { + throw new CapletsError( + "REQUEST_INVALID", + "projectConfigPath must be /.caplets/config.json.", + ); } - throw new CapletsError( - "REQUEST_INVALID", - "projectConfigPath must be /.caplets/config.json.", - ); + if (!existsSync(expectedProjectConfigPath)) return expectedProjectConfigPath; + const expected = realpathSync(expectedProjectConfigPath); + if (!pathIsInside(expected, projectRoot)) { + throw new CapletsError("REQUEST_INVALID", "projectConfigPath must resolve inside projectRoot."); + } + return expected; +} + +function pathIsInside(candidate: string, root: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } function attachEventSource( @@ -915,7 +1007,10 @@ export async function serveHttpWithSessionFactory( options: HttpServeOptions, createSession: HttpMcpSessionFactory, writeErr: (value: string) => void = (value) => process.stderr.write(value), - io: Pick = {}, + io: Pick< + HttpServeIo, + "attachSessionFactory" | "defaultAttachSessionFactory" | "exposeAttach" + > = {}, ): Promise { const resolvedEngineOptions = { exposeLocalArtifactPaths: false }; const engine = new CapletsEngine(resolvedEngineOptions); @@ -924,6 +1019,9 @@ export async function serveHttpWithSessionFactory( exposeAttach: io.exposeAttach ?? false, sessionFactory: createSession, ...(io.attachSessionFactory ? { attachSessionFactory: io.attachSessionFactory } : {}), + ...(io.defaultAttachSessionFactory + ? { defaultAttachSessionFactory: io.defaultAttachSessionFactory } + : {}), control: { ...resolvedEngineOptions, projectCapletsRoot: resolveProjectCapletsRoot(), diff --git a/packages/core/src/serve/index.ts b/packages/core/src/serve/index.ts index 68dcb3ac..4a2302a1 100644 --- a/packages/core/src/serve/index.ts +++ b/packages/core/src/serve/index.ts @@ -8,7 +8,7 @@ import { import { createNativeCapletsService } from "../native/service"; import type { NativeCapletsService } from "../native/service"; import { isCapletsCloudUrl } from "../remote/options"; -import { serveHttp, serveHttpWithSessionFactory } from "./http"; +import { CAPLETS_STACK_CHAIN_HEADER, serveHttp, serveHttpWithSessionFactory } from "./http"; import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "./options"; import { NativeCapletsMcpSession } from "./native-session"; import { serveStdio } from "./stdio"; @@ -54,47 +54,116 @@ async function serveHttpWithUpstream( engineOptions: CapletsEngineOptions, writeErr?: (value: string) => void, ): Promise { + const stackChain = [serveStackIdentity(resolved)]; await serveHttpWithSessionFactory( resolved, - async () => { - const service = createUpstreamNativeService(upstreamUrl, engineOptions, writeErr); - await service.reload(); - return new NativeCapletsMcpSession(service); - }, + async () => + new NativeCapletsMcpSession( + await createReloadedUpstreamService(upstreamUrl, engineOptions, writeErr, {}, stackChain), + ), writeErr, { exposeAttach: true, - attachSessionFactory: async (metadata) => { - const service = createUpstreamNativeService(upstreamUrl, engineOptions, writeErr, metadata); - await service.reload(); - return nativeAttachSession(service); + defaultAttachSessionFactory: async (_metadata, context) => + nativeAttachSession( + await createReloadedUpstreamService( + upstreamUrl, + engineOptions, + writeErr, + {}, + context.stackChain, + ), + ), + attachSessionFactory: async (metadata, context) => { + return nativeAttachSession( + await createReloadedUpstreamService( + upstreamUrl, + engineOptions, + writeErr, + metadata, + context.stackChain, + ), + ); }, }, ); } +async function createReloadedUpstreamService( + upstreamUrl: string, + engineOptions: CapletsEngineOptions, + writeErr?: (value: string) => void, + metadata: AttachSessionMetadata = {}, + stackChain: string[] = [], +): Promise { + const service = createUpstreamNativeService( + upstreamUrl, + engineOptions, + writeErr, + metadata, + stackChain, + ); + try { + await service.reload(); + return service; + } catch (error) { + await service.close().catch(() => undefined); + throw error; + } +} + function createUpstreamNativeService( upstreamUrl: string, engineOptions: CapletsEngineOptions, writeErr?: (value: string) => void, metadata: AttachSessionMetadata = {}, + stackChain: string[] = [], ): NativeCapletsService { return createNativeCapletsService({ ...engineOptions, ...(metadata.projectRoot ? { projectRoot: metadata.projectRoot } : {}), ...(metadata.projectConfigPath ? { projectConfigPath: metadata.projectConfigPath } : {}), mode: isCapletsCloudUrl(upstreamUrl) ? "cloud" : "remote", - remote: { url: upstreamUrl }, + remote: { + url: upstreamUrl, + ...(stackChain.length > 0 + ? { requestHeaders: { [CAPLETS_STACK_CHAIN_HEADER]: stackChain.join(",") } } + : {}), + }, ...(writeErr ? { writeErr } : {}), }); } +function serveStackIdentity(options: Extract): string { + const origin = options.publicOrigin ?? `http://${formatHost(options.host)}:${options.port}`; + const url = new URL(origin); + url.pathname = options.path; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function formatHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + function nativeAttachSession(service: NativeCapletsService) { + let cachedProjection: Awaited> | undefined; + const getProjection = async () => { + cachedProjection ??= await buildNativeAttachProjection(service); + return cachedProjection; + }; + const unsubscribe = service.onToolsChanged(() => { + cachedProjection = undefined; + }); return { - manifest: async () => (await buildNativeAttachProjection(service)).manifest, + manifest: async () => (await getProjection()).manifest, invoke: async (request: AttachInvokeRequest) => - await invokeNativeAttachExport(service, await buildNativeAttachProjection(service), request), + await invokeNativeAttachExport(service, await getProjection(), request), onManifestChanged: (listener: () => void) => service.onToolsChanged(() => listener()), - close: async () => await service.close(), + close: async () => { + unsubscribe(); + await service.close(); + }, }; } diff --git a/packages/core/test/attach-api.test.ts b/packages/core/test/attach-api.test.ts index f0cfad78..5c473ef2 100644 --- a/packages/core/test/attach-api.test.ts +++ b/packages/core/test/attach-api.test.ts @@ -3,6 +3,7 @@ import { buildAttachProjection, buildNativeAttachProjection, invokeAttachExport, + invokeNativeAttachExport, type AttachProjection, } from "../src/attach/api"; import type { CapletsEngine } from "../src/engine"; @@ -193,6 +194,54 @@ describe("Attach API dispatch", () => { ]); }); + it("preserves native direct tool identity in attach manifests", async () => { + const service = { + listTools: () => [ + { + caplet: "docs__read", + sourceCaplet: "docs", + toolName: "caplets__docs__read", + title: "read", + description: "Read docs.", + promptGuidance: [], + inputSchema: { type: "object", properties: { path: { type: "string" } } }, + outputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + shadowing: "namespace", + }, + ], + execute: vi.fn(async () => ({ ok: true })), + reload: vi.fn(), + onToolsChanged: vi.fn(), + close: vi.fn(), + } as unknown as NativeCapletsService; + + const projection = await buildNativeAttachProjection(service); + + expect(projection.manifest.caplets).toEqual([]); + expect(projection.manifest.tools).toEqual([ + expect.objectContaining({ + stableId: "native-tool:docs__read", + kind: "tool", + name: "docs__read", + downstreamName: "read", + capletId: "docs", + shadowing: "namespace", + annotations: { readOnlyHint: true }, + }), + ]); + + await expect( + invokeNativeAttachExport(service, projection, { + revision: projection.manifest.revision, + kind: "tool", + exportId: projection.manifest.tools[0]!.exportId, + input: { path: "README.md" }, + }), + ).resolves.toEqual({ ok: true }); + expect(service.execute).toHaveBeenCalledWith("docs__read", { path: "README.md" }); + }); + it("preserves native Code Mode caplets in attach manifests", async () => { const service = { listTools: () => [ diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index 380d6300..8da728f8 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -289,6 +289,49 @@ describe("RemoteNativeCapletsService", () => { } }); + it("falls back to a plain attach manifest when project-context sessions are rejected", async () => { + const requests: Array<{ url: string; method: string; headers: Headers }> = []; + const fetchStub: typeof fetch = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers); + requests.push({ url, method, headers }); + if (url.endsWith("/sessions")) { + return Response.json( + { + ok: false, + error: { + code: "REQUEST_INVALID", + message: "Attach session project context is only accepted by loopback runtimes.", + }, + }, + { status: 400 }, + ); + } + if (url.endsWith("/manifest")) { + return Response.json(attachManifest("rev-1", "export-1")); + } + return Response.json({ ok: true }); + }); + const client = createSdkRemoteCapletsClient({ + url: new URL("https://caplets.example.com/v1/attach"), + requestInit: {}, + fetch: fetchStub, + auth: { enabled: false, user: "caplets" }, + pollIntervalMs: 60_000, + attachSessionMetadata: { projectRoot: "/repo" }, + }); + + await client.listTools(); + await client.close(); + + expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([ + "POST https://caplets.example.com/v1/attach/sessions", + "GET https://caplets.example.com/v1/attach/manifest", + ]); + expect(requests[1]?.headers.get(CAPLETS_ATTACH_SESSION_HEADER)).toBeNull(); + }); + it("recreates attach sessions when the remote forgets the previous session", async () => { const requests: Array<{ url: string; method: string; headers: Headers }> = []; const fetchStub: typeof fetch = vi.fn(async (input, init) => { @@ -3613,7 +3656,7 @@ describe("createNativeCapletsService remote mode", () => { }, }); dirs.push(dir); - const projectRoot = dirname(projectConfigPath); + const projectRoot = dirname(dirname(projectConfigPath)); const service = createNativeCapletsService({ mode: "remote", @@ -3671,7 +3714,7 @@ describe("createNativeCapletsService remote mode", () => { remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, - projectRoot: dirname(projectConfigPath), + projectRoot: dirname(dirname(projectConfigPath)), writeErr, }); @@ -3728,7 +3771,7 @@ describe("createNativeCapletsService remote mode", () => { remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, - projectRoot: dirname(projectConfigPath), + projectRoot: dirname(dirname(projectConfigPath)), writeErr, }); @@ -3756,6 +3799,127 @@ describe("createNativeCapletsService remote mode", () => { ); }); + it("stops retrying self-hosted Project Binding when upstream reports unsupported capability", async () => { + const fixture = client([{ name: "remote", title: "Remote", description: "Remote Caplet." }]); + 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( + { + ok: false, + error: { + code: "UNSUPPORTED_CAPABILITY", + message: + "Self-hosted Project Binding sessions are not implemented by this runtime.", + }, + }, + { status: 501 }, + ); + } + 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(writeErr).toHaveBeenCalledWith( + "Could not start upstream Project Binding: Self-hosted Project Binding sessions are not implemented by this runtime.\n", + ), + ); + await service.reload(); + await service.reload(); + + expect( + fetch.mock.calls.filter( + ([input, init]) => + new URL(input.toString()).pathname.endsWith("/v1/attach/project-bindings/sessions") && + init?.method === "POST", + ), + ).toHaveLength(1); + await service.close(); + }); + + it("re-registers self-hosted Project Binding after heartbeat failure", async () => { + vi.useFakeTimers(); + try { + const fixture = client([{ name: "remote", title: "Remote", description: "Remote Caplet." }]); + let sessionCount = 0; + let heartbeatCount = 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") { + heartbeatCount += 1; + if (heartbeatCount === 1) { + return new Response("expired", { status: 503 }); + } + return Response.json({ ok: true }); + } + return Response.json({ ok: true }); + }, + ); + 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(sessionCount).toBe(1)); + await vi.advanceTimersByTimeAsync(30_000); + await vi.waitFor(() => + expect(writeErr).toHaveBeenCalledWith( + "Remote Project Binding heartbeat failed: Project Binding request failed (503).\n", + ), + ); + await service.reload(); + await vi.waitFor(() => expect(sessionCount).toBe(2)); + await service.close(); + } finally { + vi.useRealTimers(); + } + }); + it("updates local presence after local overlay reload changes the Caplet set", async () => { const fixture = client(); const fetch = vi.fn( diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index de7830cc..c80239cf 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -6,7 +6,7 @@ import { CapletsEngine } 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 { createHttpServeApp } from "../src/serve/http"; +import { CAPLETS_STACK_CHAIN_HEADER, createHttpServeApp } from "../src/serve/http"; import { CAPLETS_ATTACH_SESSION_HEADER, type AttachManifest } from "../src/attach/api"; import type { HttpServeOptions } from "../src/serve/options"; @@ -1125,7 +1125,7 @@ describe("createHttpServeApp", () => { it("routes attach manifest, invoke, and events through an attach session", async () => { const { engine } = testEngine(); - const projectRoot = tempDir("caplets-attach-session-project-"); + const projectRoot = realpathSync(tempDir("caplets-attach-session-project-")); const projectConfigPath = join(projectRoot, ".caplets", "config.json"); const closed: string[] = []; let eventListener: (() => void) | undefined; @@ -1217,6 +1217,100 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("routes attach manifest and invoke through the default attach session without a session header", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + defaultAttachSessionFactory: () => ({ + manifest: async () => attachManifest("default-rev", "default-tool"), + invoke: async (request) => ({ invoked: request.exportId }), + onManifestChanged: () => () => undefined, + close: async () => undefined, + }), + }); + + const manifestResponse = await app.request("http://127.0.0.1:5387/v1/attach/manifest"); + expect(manifestResponse.status).toBe(200); + const manifest = (await manifestResponse.json()) as AttachManifest; + expect(manifest.revision).toBe("default-rev"); + + 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: "default-rev", + kind: "caplet", + exportId: "session-export", + input: {}, + }), + }); + expect(invoked.status).toBe(200); + await expect(invoked.json()).resolves.toEqual({ + ok: true, + data: { invoked: "session-export" }, + }); + + await app.closeCapletsSessions(); + await engine.close(); + }); + + it("rejects attach requests that would cycle through the same stacked runtime", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + defaultAttachSessionFactory: () => { + throw new Error("default attach session should not be created"); + }, + }); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { [CAPLETS_STACK_CHAIN_HEADER]: "http://127.0.0.1:5387/" }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { + code: "REQUEST_INVALID", + message: "Stacked runtime upstream cycle detected.", + }, + }); + + await app.closeCapletsSessions(); + await engine.close(); + }); + + it("defaults attach session project config path from project root", async () => { + const { engine } = testEngine(); + const projectRoot = realpathSync(tempDir("caplets-attach-session-project-")); + const projectConfigPath = join(projectRoot, ".caplets", "config.json"); + let captured: unknown; + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + attachSessionFactory: (metadata) => { + captured = metadata; + return { + manifest: async () => attachManifest("session-rev", "session-tool"), + invoke: async () => ({ ok: true }), + onManifestChanged: () => () => undefined, + close: async () => undefined, + }; + }, + }); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot }), + }); + + expect(response.status).toBe(201); + expect(captured).toEqual({ projectRoot, projectConfigPath }); + + await app.closeCapletsSessions(); + await engine.close(); + }); + it("rejects attach session project config paths outside the project root", async () => { const { engine } = testEngine(); const projectRoot = tempDir("caplets-attach-session-project-"); @@ -1246,6 +1340,40 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("rejects attach session project config symlinks that resolve outside the project root", async () => { + const { engine } = testEngine(); + const projectRoot = realpathSync(tempDir("caplets-attach-session-project-")); + const outsideDir = tempDir("caplets-attach-session-outside-"); + const outsideConfig = join(outsideDir, "config.json"); + writeFileSync(outsideConfig, "{}", "utf8"); + mkdirSync(join(projectRoot, ".caplets"), { recursive: true }); + symlinkSync(outsideConfig, join(projectRoot, ".caplets", "config.json")); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + attachSessionFactory: () => { + throw new Error("session factory should not run"); + }, + }); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectRoot }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { + code: "REQUEST_INVALID", + message: "projectConfigPath must resolve inside projectRoot.", + }, + }); + + await app.closeCapletsSessions(); + await engine.close(); + }); + it("rejects attach session project roots that are not directories", async () => { const { engine } = testEngine(); const projectRoot = tempDir("caplets-attach-session-project-"); @@ -1405,6 +1533,37 @@ describe("createHttpServeApp", () => { } }); + it("prunes idle attach sessions without waiting for another request", async () => { + vi.useFakeTimers(); + const { engine } = testEngine(); + const close = vi.fn(async () => undefined); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + attachSessionFactory: () => ({ + manifest: async () => attachManifest("session-rev", "session-tool"), + invoke: async () => ({ ok: true }), + onManifestChanged: () => () => undefined, + close, + }), + }); + try { + const created = await app.request("http://127.0.0.1:5387/v1/attach/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(created.status).toBe(201); + + await vi.advanceTimersByTimeAsync(11 * 60_000); + + expect(close).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + await app.closeCapletsSessions(); + await engine.close(); + } + }); + it("invokes exported attach entries by revision-scoped export ID", async () => { const { engine } = testEngine({ options: { exposure: "progressive" }, From 4578a0f8d913127ac13871d0906afab194c40355 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Wed, 24 Jun 2026 05:31:53 -0400 Subject: [PATCH 3/3] fix(core): harden stacked attach session handling --- packages/core/src/native/service.ts | 14 +++- packages/core/src/serve/http.ts | 58 ++++++++++++++-- packages/core/src/serve/index.ts | 1 + packages/core/test/native-remote.test.ts | 8 ++- packages/core/test/serve-http.test.ts | 87 ++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 10 deletions(-) diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index 5955677b..20018ea0 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import { resolve as resolvePath } from "node:path"; import { isLoopbackHost } from "../server/options"; import type { NativeCapletsServiceResolutionInput } from "./options"; @@ -701,12 +702,21 @@ function attachSessionMetadataForOptions( options: NativeCapletsServiceOptions, ): SdkRemoteCapletsClientOptions["attachSessionMetadata"] { if (!options.projectRoot) return undefined; + const projectRoot = canonicalProjectRootForMetadata(options.projectRoot); return { - projectRoot: options.projectRoot, - projectConfigPath: resolvePath(options.projectRoot, ".caplets", "config.json"), + projectRoot, + projectConfigPath: resolvePath(projectRoot, ".caplets", "config.json"), }; } +function canonicalProjectRootForMetadata(projectRoot: string): string { + try { + return realpathSync(projectRoot); + } catch { + return projectRoot; + } +} + function isLoopbackRemote(remoteOptions: ResolvedNativeRemoteOptions): boolean { return remoteOptions.url.protocol === "http:" && isLoopbackHost(remoteOptions.url.hostname); } diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index b9e21f87..dc8e1476 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -24,6 +24,7 @@ import { import { RemoteAuthFlowStore } from "../remote-control/auth-flow"; import type { RemoteCliRequest } from "../remote-control/types"; import { RemoteServerCredentialStore } from "../remote/server-credential-store"; +import { isLoopbackHost } from "../server/options"; import type { HttpServeOptions } from "./options"; import { CapletsMcpSession } from "./session"; @@ -349,7 +350,9 @@ export function createHttpServeApp( try { const parsed = await parseJsonObject(c.req.json(), "Attach session request"); const metadata = parseAttachSessionMetadata(parsed, { - allowProjectContext: options.loopback, + allowProjectContext: allowAttachSessionProjectContext(options, c.req.url, (name) => + c.req.header(name), + ), }); const context = attachSessionContext(c.req.header(CAPLETS_STACK_CHAIN_HEADER)); const sessionId = randomUUID(); @@ -860,13 +863,17 @@ function canonicalProjectConfigPath( throw new CapletsError("REQUEST_INVALID", "projectConfigPath requires projectRoot."); } const expectedProjectConfigPath = resolve(projectRoot, ".caplets", "config.json"); - const absoluteConfigPath = + const lexicalConfigPath = projectConfigPath === undefined ? expectedProjectConfigPath : isAbsolute(projectConfigPath) ? projectConfigPath : resolve(projectRoot, projectConfigPath); - if (resolve(absoluteConfigPath) !== expectedProjectConfigPath) { + const canonicalConfigPath = + projectConfigPath === undefined + ? expectedProjectConfigPath + : canonicalizeExistingParentPath(lexicalConfigPath); + if (resolve(canonicalConfigPath) !== expectedProjectConfigPath) { throw new CapletsError( "REQUEST_INVALID", "projectConfigPath must be /.caplets/config.json.", @@ -880,11 +887,47 @@ function canonicalProjectConfigPath( return expected; } +function canonicalizeExistingParentPath(path: string): string { + const parent = dirname(path); + try { + return resolve(realpathSync(parent), path.slice(parent.length + 1)); + } catch { + return resolve(path); + } +} + function pathIsInside(candidate: string, root: string): boolean { const rel = relative(root, candidate); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } +function allowAttachSessionProjectContext( + options: HttpServeOptions, + requestUrl: string, + header: (name: string) => string | undefined, +): boolean { + if (!options.loopback) return false; + const host = attachRequestHost(options, requestUrl, header); + return isLoopbackHost(host); +} + +function attachRequestHost( + options: HttpServeOptions, + requestUrl: string, + header: (name: string) => string | undefined, +): string { + const fallback = new URL(requestUrl).host; + const forwardedHost = options.trustProxy + ? firstForwardedValue(header("x-forwarded-host")) + : undefined; + const host = forwardedHost ?? header("host") ?? fallback; + try { + return new URL(`http://${host}`).hostname; + } catch { + return host.split(":")[0] ?? host; + } +} + function attachEventSource( engine: CapletsEngine, session: HttpAttachSession | undefined, @@ -1011,8 +1054,13 @@ export async function serveHttpWithSessionFactory( HttpServeIo, "attachSessionFactory" | "defaultAttachSessionFactory" | "exposeAttach" > = {}, + engineOptions: CapletsEngineOptions = {}, ): Promise { - const resolvedEngineOptions = { exposeLocalArtifactPaths: false }; + const resolvedEngineOptions = { + exposeLocalArtifactPaths: false, + vaultRecoveryTarget: "remote" as const, + ...engineOptions, + }; const engine = new CapletsEngine(resolvedEngineOptions); const app = createHttpServeApp(options, engine, { writeErr, @@ -1024,7 +1072,7 @@ export async function serveHttpWithSessionFactory( : {}), control: { ...resolvedEngineOptions, - projectCapletsRoot: resolveProjectCapletsRoot(), + projectCapletsRoot: projectCapletsRootForEngineOptions(resolvedEngineOptions), }, }); const paths = servicePaths(options.path); diff --git a/packages/core/src/serve/index.ts b/packages/core/src/serve/index.ts index 4a2302a1..040d0126 100644 --- a/packages/core/src/serve/index.ts +++ b/packages/core/src/serve/index.ts @@ -86,6 +86,7 @@ async function serveHttpWithUpstream( ); }, }, + engineOptions, ); } diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index 8da728f8..7266a9f5 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -3596,7 +3596,7 @@ describe("createNativeCapletsService remote mode", () => { url: "https://cloud.caplets.dev", accessToken: "token", workspaceId: "ws_1", - projectRoot: dirname(projectConfigPath), + projectRoot: dirname(dirname(projectConfigPath)), heartbeatIntervalMs: 60_000, }, }, @@ -3614,7 +3614,9 @@ describe("createNativeCapletsService remote mode", () => { .map( (body) => JSON.parse(body) as { projectFiles?: Array<{ path: string; content: string }> }, ); - expect(projectBindingBodies[0]?.projectFiles).toEqual([{ path: "config.json", content: "{}" }]); + expect(projectBindingBodies[0]?.projectFiles).toEqual([ + { path: ".caplets/config.json", content: "{}" }, + ]); expect(fetch).toHaveBeenCalledWith( new URL("https://cloud.caplets.dev/api/project-bindings/presence_1"), expect.objectContaining({ @@ -3949,7 +3951,7 @@ describe("createNativeCapletsService remote mode", () => { url: "https://cloud.caplets.dev", accessToken: "token", workspaceId: "ws_1", - projectRoot: dirname(projectConfigPath), + projectRoot: dirname(dirname(projectConfigPath)), }, }, remoteClientFactory: vi.fn(() => fixture.api), diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index c80239cf..803cad75 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -1466,6 +1466,93 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("rejects attach session project context through a configured public origin", async () => { + const { engine } = testEngine(); + const store = remoteCredentialStore(); + const credentials = pairedClient(store, "https://caplets.tail7ff085.ts.net/"); + const projectRoot = tempDir("caplets-attach-session-project-"); + const app = createHttpServeApp( + httpOptions({ + publicOrigin: "https://caplets.tail7ff085.ts.net", + auth: { type: "remote_credentials" }, + }), + engine, + { + writeErr: () => {}, + remoteCredentialStore: store, + attachSessionFactory: () => { + throw new Error("session factory should not run"); + }, + }, + ); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/sessions", { + method: "POST", + headers: { + "content-type": "application/json", + host: "caplets.tail7ff085.ts.net", + authorization: `Bearer ${credentials.accessToken}`, + }, + body: JSON.stringify({ projectRoot }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { + code: "REQUEST_INVALID", + message: "Attach session project context is only accepted by loopback runtimes.", + }, + }); + + await app.closeCapletsSessions(); + await engine.close(); + }); + + it("canonicalizes symlinked attach session project config paths", async () => { + const { engine } = testEngine(); + const projectRoot = tempDir("caplets-attach-session-project-"); + const canonicalProjectRoot = realpathSync(projectRoot); + const projectConfigPath = join(canonicalProjectRoot, ".caplets", "config.json"); + mkdirSync(join(canonicalProjectRoot, ".caplets"), { recursive: true }); + writeFileSync(projectConfigPath, "{}", "utf8"); + const symlinkedProjectRoot = tempDir("caplets-attach-session-link-parent-"); + rmSync(symlinkedProjectRoot, { recursive: true, force: true }); + symlinkSync(projectRoot, symlinkedProjectRoot, "dir"); + dirs.push(symlinkedProjectRoot); + let metadata: unknown; + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + attachSessionFactory: (sessionMetadata) => { + metadata = sessionMetadata; + return { + manifest: async () => attachManifest("session-rev", "session-tool"), + invoke: async () => ({ ok: true }), + onManifestChanged: () => () => undefined, + close: async () => undefined, + }; + }, + }); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + projectRoot: symlinkedProjectRoot, + projectConfigPath: join(symlinkedProjectRoot, ".caplets", "config.json"), + }), + }); + + expect(response.status).toBe(201); + expect(metadata).toEqual({ + projectRoot: canonicalProjectRoot, + projectConfigPath, + }); + + await app.closeCapletsSessions(); + await engine.close(); + }); + it("returns structured errors for unknown attach session headers", async () => { const { engine } = testEngine(); const app = createHttpServeApp(httpOptions(), engine, {