diff --git a/.changeset/daemon-first-setup.md b/.changeset/daemon-first-setup.md new file mode 100644 index 00000000..01061680 --- /dev/null +++ b/.changeset/daemon-first-setup.md @@ -0,0 +1,10 @@ +--- +"@caplets/core": patch +"caplets": patch +"@caplets/opencode": patch +"@caplets/pi": patch +--- + +Promote daemon-first local setup. `caplets setup` now initializes config, starts or reuses the local daemon, verifies health before mutating integrations, and configures MCP clients as thin `caplets attach ` clients through the pinned `add-mcp` adapter. + +Add explicit native daemon mode and setup-written daemon defaults for OpenCode and Pi, while keeping remote/cloud setup on Remote Login and secret-free attach paths. diff --git a/.changeset/global-serve-config.md b/.changeset/global-serve-config.md new file mode 100644 index 00000000..d95f891e --- /dev/null +++ b/.changeset/global-serve-config.md @@ -0,0 +1,6 @@ +--- +"@caplets/core": patch +"caplets": patch +--- + +Add top-level user `serve` config defaults for HTTP Caplets serving. Foreground `caplets serve --transport http` and daemon restarts can now reuse configured host, port, path, upstream URL, remote state path, public origins, proxy trust, and unauthenticated HTTP intent while project config ignores `serve` for security. diff --git a/CONCEPTS.md b/CONCEPTS.md index a40216b3..9bf9252c 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -66,6 +66,12 @@ A per-user native service managed by `caplets daemon` that runs local HTTP `capl The Caplets Daemon is installed and updated through an install-time service contract. Runtime lifecycle commands operate on the installed service rather than changing its persisted serve or environment configuration. +### Daemon-First Setup + +The local onboarding path where `caplets setup` prepares the user config and a healthy Caplets Daemon before configuring agent integrations. + +Daemon-First Setup points MCP clients at `caplets attach ` and points native integrations at the local daemon runtime, so backend execution uses the daemon-owned environment instead of depending on MCP client environment passthrough. + ### Caplets Vault A runtime-owned encrypted string store whose values can be referenced from Caplets config with `$vault:NAME` or `${vault:NAME}`. @@ -184,6 +190,12 @@ A local HTTP Caplets runtime that serves local Caplets while composing an upstre 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. +### Public Origin + +An externally meaningful origin for a Caplets HTTP serve process. + +Public Origins participate in host/audience identity for HTTP serve, Remote Login, and attach routes. They are not a project-controlled allowlist or a general network authorization policy. + ### 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 20bbf861..c4410e85 100644 --- a/README.md +++ b/README.md @@ -65,19 +65,43 @@ caplets install caplets update osv ``` -Or add Caplets manually to any MCP client: +`caplets setup` is the recommended local path. It creates or reuses your Caplets config, +starts the local Caplets daemon, and configures the agent as a thin client that runs +`caplets attach `. The daemon owns backend execution, environment, +Vault values, reloads, and health while the agent config stays stable and secret-free. + +Manual daemon-backed MCP config looks like this: ```json { "mcpServers": { "caplets": { "command": "caplets", - "args": ["serve"] + "args": ["attach", ""] } } } ``` +You can put HTTP serve defaults in your user Caplets config when you run a foreground +HTTP server or want daemon restarts to reuse a non-default port, path, upstream, or public +origin. These defaults live under top-level `serve`, are ignored from project config for +security, and lose to command flags and environment variables: + +```json +{ + "serve": { + "host": "127.0.0.1", + "port": 5387, + "publicOrigins": ["https://caplets.example.com"] + } +} +``` + +`serve.publicOrigins` are full origins used for public request identity, not host-only +allowlists. `caplets setup` still prepares a credential-free loopback daemon before +mutating agent config, even if your user `serve` defaults describe a broader HTTP runtime. + ## Use Caplets Add your own capability sources: @@ -107,32 +131,40 @@ handles so discovery, execution, filtering, and synthesis can happen in one call ## Agent Surfaces -Caplets works as a regular MCP server through `caplets serve`. By default, that server exposes -Code Mode for the configured backends. Caplets also has native integrations for agents that can -load packages directly: +Caplets' default local agent setup is daemon-first. `caplets setup` initializes user +configuration, installs or starts the local Caplets daemon, checks health, and then +configures the selected agent as a thin attach/native client. This avoids relying on +each MCP client to inherit the same shell environment as your terminal; backend +execution happens in the Caplets daemon instead. -| Agent | Setup | -| ----------------------------------------- | ----------------------------------------------------------------------------------------------- | -| Codex, Claude Code, and other MCP clients | `caplets setup` or `caplets serve` | -| OpenCode | [`@caplets/opencode`](https://github.com/spiritledsoftware/caplets/tree/main/packages/opencode) | -| Pi | [`@caplets/pi`](https://github.com/spiritledsoftware/caplets/tree/main/packages/pi) | +| Agent | Recommended local setup | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Codex, Claude Code, and other MCP clients | `caplets setup` or `caplets setup mcp-client --client codex` for an explicit add-mcp client target | +| OpenCode | `caplets setup opencode` or [`@caplets/opencode`](https://github.com/spiritledsoftware/caplets/tree/main/packages/opencode) | +| Pi | `caplets setup pi` or [`@caplets/pi`](https://github.com/spiritledsoftware/caplets/tree/main/packages/pi) | -`caplets setup` uses each harness's MCP configuration command: - -```sh -codex mcp add caplets -- caplets serve -claude mcp add --transport stdio --scope user caplets -- caplets serve -``` - -Equivalent local Codex config: +For MCP clients, setup uses the `add-mcp` client catalog under the hood and writes a +Caplets server command shaped like this: ```toml [mcp_servers.caplets] command = "caplets" -args = ["serve"] +args = ["attach", ""] +``` + +```json +{ + "mcpServers": { + "caplets": { + "command": "caplets", + "args": ["attach", ""] + } + } +} ``` -For a remote or Cloud-backed MCP server, point the client at `caplets attach` instead: +For a remote or Cloud-backed MCP server, keep the same thin-client shape and point +`caplets attach` at the remote URL after Remote Login: ```toml [mcp_servers.caplets] @@ -151,9 +183,9 @@ 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: +`caplets attach ` is always the stdio client command for MCP configs. As an +advanced manual fallback, you can still run a foreground HTTP runtime yourself, for +example to compose local/project Caplets with an upstream host: ```sh caplets serve --transport http --upstream-url https://caplets.example.com/caplets @@ -163,7 +195,8 @@ 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____`. +exposure adds operation-level tools such as `caplets____`. `caplets setup` +writes non-secret daemon defaults for OpenCode and Pi; explicit plugin settings still win. Remote mode uses Remote Login for both self-hosted Caplets and Caplets Cloud. Trust the host once, then launch attach or a native integration with only non-secret selectors: diff --git a/apps/docs/src/content/docs/agent-integrations.mdx b/apps/docs/src/content/docs/agent-integrations.mdx index f79e9aa7..5352dd1c 100644 --- a/apps/docs/src/content/docs/agent-integrations.mdx +++ b/apps/docs/src/content/docs/agent-integrations.mdx @@ -6,20 +6,26 @@ description: Use Caplets with Codex, Claude, OpenCode, and Pi. Caplets works with Codex, Claude, OpenCode, Pi, and other MCP clients. Code Mode is the default surface for configured capabilities. +Local setup is daemon-first: `caplets setup` initializes user Caplets config, installs or +starts the local Caplets daemon, verifies daemon health, and then configures the selected +agent as a thin `caplets attach ` or native client. This solves the +environment passthrough problem in clients that do not launch MCP servers with the same +shell environment as your terminal; backend execution happens in the Caplets daemon. + ## Codex Run setup: ```sh -caplets setup +caplets setup codex ``` -Manual local MCP config: +Equivalent daemon-backed local MCP config: ```toml [mcp_servers.caplets] command = "caplets" -args = ["serve"] +args = ["attach", ""] ``` ## Claude @@ -27,13 +33,13 @@ args = ["serve"] Run setup: ```sh -caplets setup +caplets setup claude-code ``` -Manual setup uses Claude's MCP command: +Manual setup should still point Claude at the daemon-backed attach client: ```sh -claude mcp add --transport stdio --scope user caplets -- caplets serve +claude mcp add --transport stdio --scope user caplets -- caplets attach http://127.0.0.1:5387/ ``` ## OpenCode @@ -41,19 +47,34 @@ claude mcp add --transport stdio --scope user caplets -- caplets serve OpenCode can use Caplets through MCP or the native OpenCode integration. Native mode exposes `caplets__code_mode` for Code Mode, plus progressive or direct tools only when configured. +```sh +caplets setup opencode +``` + +Local setup installs the native plugin and writes non-secret daemon defaults. Explicit +OpenCode plugin config still wins over those defaults. + Native integrations use the shared anonymous telemetry controls and do not send telemetry on a native-first run until a visible CLI telemetry notice has already been recorded. Remote native usage: ```sh +caplets remote login https://caplets.example.com/caplets CAPLETS_MODE=remote CAPLETS_REMOTE_URL=https://caplets.example.com/caplets opencode ``` ## Pi -Pi can use Caplets through MCP or the native Pi integration. Native mode uses the same local -and remote selection rules as OpenCode. +Pi can use Caplets through MCP or the native Pi integration. Native mode uses the same local, +daemon, remote, and Cloud selection rules as OpenCode. + +```sh +caplets setup pi +``` + +Local setup installs the Pi extension and writes non-secret daemon defaults. Explicit Pi +settings still win over those defaults. Native integrations use the shared anonymous telemetry controls and do not send telemetry on a native-first run until a visible CLI telemetry notice has already been recorded. @@ -61,28 +82,38 @@ native-first run until a visible CLI telemetry notice has already been recorded. Remote native usage: ```sh +caplets remote login https://caplets.example.com/caplets CAPLETS_MODE=remote CAPLETS_REMOTE_URL=https://caplets.example.com/caplets pi ``` ## Other MCP clients -Use: +Use the `add-mcp` client catalog through Caplets setup: + +```sh +caplets setup mcp-client --client +``` + +If you write config manually, keep the agent as a thin daemon client: ```json { "mcpServers": { "caplets": { "command": "caplets", - "args": ["serve"] + "args": ["attach", ""] } } } ``` -For remote-backed MCP, use `caplets attach` instead of `caplets serve`. +For remote-backed MCP, use `caplets attach ` after `caplets remote login +`. Agent configs should not contain Caplets remote tokens, passwords, or +provider environment variables. -`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: +As an advanced manual fallback, `caplets serve` can still run a foreground HTTP or stdio +runtime. If you want one local HTTP runtime to own environment, Vault, Remote Login, +reloads, and logs while composing an upstream host, run: ```sh caplets serve --transport http --upstream-url https://caplets.example.com/caplets diff --git a/apps/docs/src/content/docs/configuration.mdx b/apps/docs/src/content/docs/configuration.mdx index a537f7ab..3879cd2b 100644 --- a/apps/docs/src/content/docs/configuration.mdx +++ b/apps/docs/src/content/docs/configuration.mdx @@ -18,6 +18,41 @@ CAPLETS_CONFIG=/path/to/caplets.json caplets doctor `CAPLETS_CONFIG` is useful in CI, tests, or temporary sessions where you want a different user config file. +### HTTP serve defaults + +User config can define optional HTTP `serve` defaults for foreground `caplets serve +--transport http` and the managed local daemon: + +```json +{ + "$schema": "https://caplets.dev/config.schema.json", + "serve": { + "host": "127.0.0.1", + "port": 5387, + "path": "/", + "publicOrigins": ["https://caplets.example.com"] + }, + "mcpServers": {} +} +``` + +Precedence is command flags, then environment variables, then user `serve` config, then +built-in defaults. `caplets daemon restart` re-resolves user `serve` defaults for daemon +fields that were not set explicitly at daemon install time. + +`serve.publicOrigins` entries are full origins, not host-only allowlists. They are used for +public request identity such as DNS rebinding checks and remote credential audiences. The +config surface intentionally has no transport field under `serve`; choose transport on the +command line. + +Keep `serve` in user config only. Project config ignores `serve` and warns because project +repositories should not control a developer's local HTTP bind address, auth posture, or +public origins. + +`caplets setup` remains safer than a hand-written MCP server. Local setup always prepares a +credential-free loopback daemon before writing agent config, even if user `serve` defaults +describe a broader foreground or self-hosted HTTP runtime. + ## Project config Project config lives at: diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index 76315f49..03eaf750 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -12,16 +12,8 @@ stdio server. ## Quick Start -Run a known-good no-auth setup first. OSV is public, so it is the fastest way to prove -your agent can see and call a Caplet. - -```sh -npx caplets setup -npx caplets install spiritledsoftware/caplets osv -npx caplets doctor -``` - -Or install it globally: +Install the CLI, then run a known-good no-auth setup first. OSV is public, so it is the +fastest way to prove your agent can see and call a Caplet. ```sh npm install -g caplets @@ -30,6 +22,14 @@ caplets install spiritledsoftware/caplets osv caplets doctor ``` +Use `npx caplets ...` only for temporary foreground commands; daemon-backed agent setup +expects a stable installed `caplets` binary on `PATH`. + +`caplets setup` is daemon-first: it initializes Caplets config, starts or reuses the +local Caplets daemon, and configures your agent as a thin `caplets attach +` or native client. The daemon owns backend execution and environment +passthrough, so agent configs do not need secrets or provider-specific environment values. + `doctor` should identify the active Caplets config path and report the checks it can run for your configured integrations. Fix any failed check before testing through an agent. diff --git a/apps/docs/src/content/docs/install.mdx b/apps/docs/src/content/docs/install.mdx index aec52a37..563c29b0 100644 --- a/apps/docs/src/content/docs/install.mdx +++ b/apps/docs/src/content/docs/install.mdx @@ -19,8 +19,9 @@ Use `npx` if you only want to try a command without installing globally: npx caplets install spiritledsoftware/caplets osv ``` -Use the global CLI before running setup. Setup writes agent configuration that launches -`caplets serve`, so the binary needs to stay on your PATH: +Use the global CLI before running setup. Setup installs or reuses the local Caplets +daemon and writes agent configuration that launches `caplets attach `, +so the binary needs to stay on your PATH: ```sh npm install -g caplets @@ -28,8 +29,10 @@ caplets setup caplets install spiritledsoftware/caplets osv ``` -`caplets setup` configures supported agent harnesses. The OSV Caplet is the recommended -first install because it is public and does not require credentials. +`caplets setup` configures supported agent harnesses. It initializes user config, +health-checks the daemon before writing agent config, and uses daemon-backed attach/native +clients so backend execution keeps the full Caplets environment. The OSV Caplet is the +recommended first install because it is public and does not require credentials. ## Install lockfiles and updates @@ -81,8 +84,32 @@ Remote project install and update semantics are intentionally separate from this ## Manual MCP setup -If your client does not support `caplets setup`, or if you are avoiding a global install, -add Caplets as a stdio MCP server: +Prefer `caplets setup` for local MCP clients. It uses the supported MCP-client catalog +and configures `caplets attach ` after the daemon is healthy. For an +explicit client target, run: + +```sh +caplets setup mcp-client --client codex +``` + +If you need to write a config by hand, keep it daemon-backed and secret-free: + +```json +{ + "mcpServers": { + "caplets": { + "command": "caplets", + "args": ["attach", ""] + } + } +} +``` + +Replace `` with the loopback daemon URL reported by `caplets setup` or +`caplets daemon status`; the default is usually `http://127.0.0.1:5387/`. + +For an advanced manual fallback where the agent intentionally launches a foreground +process instead of attaching to the daemon, configure `caplets serve` directly: ```json { @@ -95,7 +122,8 @@ add Caplets as a stdio MCP server: } ``` -Use `npx` in the command if you did not install globally: +Use `npx` in the command only for temporary foreground experiments; daemon-backed setup +expects a stable installed `caplets` binary on PATH: ```json { @@ -108,6 +136,24 @@ Use `npx` in the command if you did not install globally: } ``` +If you need non-default HTTP serving behavior, prefer user config over repeating flags: + +```json +{ + "serve": { + "port": 5487, + "path": "/caplets", + "publicOrigins": ["https://caplets.example.com"] + } +} +``` + +These `serve` defaults are user-only. They are ignored from `.caplets/config.json`, do not +set transport under `serve`, and are overridden by command flags and environment variables. A +daemon restart picks up changed user defaults for daemon fields that were not explicitly +set during daemon install. Local `caplets setup` still uses a credential-free loopback +daemon before writing MCP or native integration config. + ## Check the install Run the doctor check after setup: diff --git a/apps/docs/src/content/docs/reference/config.mdx b/apps/docs/src/content/docs/reference/config.mdx index 82970532..7cfa8e3c 100644 --- a/apps/docs/src/content/docs/reference/config.mdx +++ b/apps/docs/src/content/docs/reference/config.mdx @@ -22,6 +22,20 @@ Minimal user config: } ``` +Global HTTP serve defaults (user config only): + +```json +{ + "$schema": "https://caplets.dev/config.schema.json", + "serve": { + "host": "127.0.0.1", + "port": 5387, + "publicOrigins": ["https://caplets.example.com"] + }, + "mcpServers": {} +} +``` + Keep `options.exposure` at the default `code_mode` unless your client cannot run Code Mode. Add backend maps such as `mcpServers`, `openapiEndpoints`, `googleDiscoveryApis`, `graphqlEndpoints`, `httpApis`, `cliTools`, or `capletSets` only @@ -62,26 +76,42 @@ Public OpenAPI endpoint: ## Top-level fields -| Field | Status | Type | Description | -| --------------------- | -------- | ------- | ---------------------------------------------------------------------- | -| `$schema` | Optional | string | Optional JSON Schema for editor validation. | -| `version` | Optional | number | Caplets config schema version. | -| `defaultSearchLimit` | Optional | integer | Default maximum number of same-server search results. | -| `maxSearchLimit` | Optional | integer | Maximum accepted search_tools limit. | -| `telemetry` | Optional | boolean | Set false to disable anonymous Caplets telemetry for this user config. | -| `completion` | Optional | object | Shell completion discovery timeout and cache settings. | -| `options` | Optional | object | Global Caplets runtime options. | -| `namespaceAliases` | Optional | object | Source-level namespace aliases for hash-qualified Caplet IDs. | -| `mcpServers` | Optional | object | Downstream MCP servers keyed by stable server ID. | -| `openapiEndpoints` | Optional | object | OpenAPI endpoints keyed by stable Caplet ID. | -| `googleDiscoveryApis` | Optional | object | Google Discovery APIs keyed by stable Caplet ID. | -| `graphqlEndpoints` | Optional | object | GraphQL endpoints keyed by stable Caplet ID. | -| `httpApis` | Optional | object | HTTP APIs keyed by stable Caplet ID. | -| `cliTools` | Optional | object | CLI tools keyed by stable Caplet ID. | -| `capletSets` | Optional | object | Nested Caplet collections keyed by stable Caplet ID. | +| Field | Status | Type | Description | +| --------------------- | -------- | ------- | ------------------------------------------------------------------------- | +| `$schema` | Optional | string | Optional JSON Schema for editor validation. | +| `version` | Optional | number | Caplets config schema version. | +| `defaultSearchLimit` | Optional | integer | Default maximum number of same-server search results. | +| `maxSearchLimit` | Optional | integer | Maximum accepted search_tools limit. | +| `telemetry` | Optional | boolean | Set false to disable anonymous Caplets telemetry for this user config. | +| `serve` | Optional | object | User-owned HTTP serve defaults. Ignored from project config for security. | +| `completion` | Optional | object | Shell completion discovery timeout and cache settings. | +| `options` | Optional | object | Global Caplets runtime options. | +| `namespaceAliases` | Optional | object | Source-level namespace aliases for hash-qualified Caplet IDs. | +| `mcpServers` | Optional | object | Downstream MCP servers keyed by stable server ID. | +| `openapiEndpoints` | Optional | object | OpenAPI endpoints keyed by stable Caplet ID. | +| `googleDiscoveryApis` | Optional | object | Google Discovery APIs keyed by stable Caplet ID. | +| `graphqlEndpoints` | Optional | object | GraphQL endpoints keyed by stable Caplet ID. | +| `httpApis` | Optional | object | HTTP APIs keyed by stable Caplet ID. | +| `cliTools` | Optional | object | CLI tools keyed by stable Caplet ID. | +| `capletSets` | Optional | object | Nested Caplet collections keyed by stable Caplet ID. | ## Major sections +### `serve` + +User-owned HTTP serve defaults. Ignored from project config for security. + +| Field | Status | Type | Description | +| -------------------------- | -------- | ------- | ---------------------------------------------------------------------------- | +| `host` | Optional | string | Default HTTP bind host for caplets serve. | +| `port` | Optional | integer | Default HTTP port. | +| `path` | Optional | string | Default HTTP base path. | +| `remoteStatePath` | Optional | string | Default remote credential state directory for HTTP serve. | +| `upstreamUrl` | Optional | string | Default upstream Caplets URL for stacked HTTP serve. | +| `allowUnauthenticatedHttp` | Optional | boolean | Opt in to unauthenticated HTTP serving; intended only for trusted local use. | +| `trustProxy` | Optional | boolean | Trust proxy headers when deriving public HTTP request URLs. | +| `publicOrigins` | Optional | array | Additional public HTTP origins. | + ### `completion` Shell completion discovery timeout and cache settings. diff --git a/apps/docs/src/content/docs/remote-attach.mdx b/apps/docs/src/content/docs/remote-attach.mdx index acd99cdf..8f1df16c 100644 --- a/apps/docs/src/content/docs/remote-attach.mdx +++ b/apps/docs/src/content/docs/remote-attach.mdx @@ -3,9 +3,10 @@ title: Remote Attach 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. `caplets attach ` is a stdio MCP adapter; -it does not run an HTTP server. +Use `caplets attach` when an agent should be a thin stdio client for a Caplets runtime. +Daemon-first local setup points clients at the local Caplets daemon with `caplets attach +`; remote and Cloud setup use the same command shape with a remote URL. +`caplets attach ` 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 @@ -15,16 +16,25 @@ upstream Caplets host. Caplets native integrations use the same mode names as `caplets attach`: -| Mode | Use it when | -| -------- | ------------------------------------------------------------------------------------------------------------------------- | -| `local` | The agent should start Caplets against local user and project config. | -| `remote` | The agent should connect to a self-hosted Caplets service. | -| `cloud` | The agent should connect to Caplets Cloud through a saved Remote Profile. | -| `auto` | Caplets should use Cloud for Cloud URLs, self-hosted remote for non-Cloud URLs, and local mode when no remote URL is set. | +| Mode | Use it when | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `local` | The native integration should start local Caplets in-process against user and project config. | +| `daemon` | The native integration should use the local Caplets daemon through a credential-free loopback attach URL. | +| `remote` | The agent should connect to a self-hosted Caplets service. | +| `cloud` | The agent should connect to Caplets Cloud through a saved Remote Profile. | +| `auto` | Caplets should use Cloud for Cloud URLs, self-hosted remote for non-Cloud URLs, daemon defaults when setup wrote them, and local mode otherwise. | ## MCP client config -Codex: +Local daemon-backed Codex config after setup: + +```toml +[mcp_servers.caplets] +command = "caplets" +args = ["attach", "http://127.0.0.1:5387/"] +``` + +Remote Codex config after Remote Login: ```toml [mcp_servers.caplets] diff --git a/apps/docs/src/content/docs/troubleshooting.mdx b/apps/docs/src/content/docs/troubleshooting.mdx index 230d1c3d..0f50fcc3 100644 --- a/apps/docs/src/content/docs/troubleshooting.mdx +++ b/apps/docs/src/content/docs/troubleshooting.mdx @@ -34,15 +34,15 @@ caplets doctor Codex, Claude, OpenCode, and Pi usually read MCP or native plugin configuration at agent startup. If `doctor` is clean but the agent still cannot see Caplets, inspect the agent's -configured MCP servers and confirm the command is `caplets serve` or the equivalent `npx` -stdio command from [Install](/install/#manual-mcp-setup). +configured MCP servers and confirm daemon-first local setup wrote `caplets attach +`. `caplets serve` should appear only in manual fallback configs. -| Agent | Verify registration | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Codex | Run `codex mcp list`, then `codex mcp get caplets`; confirm `caplets serve` or `npx --yes caplets serve`. | -| Claude | Run `claude mcp list`, then `claude mcp get caplets`; confirm the same command shape. | -| Other MCP clients | Inspect the client MCP JSON and confirm the `caplets` server command and args from [Install](/install/#manual-mcp-setup). | -| OpenCode / Pi native | Confirm the native integration starts with `CAPLETS_MODE` or remote values and exposes {"caplets\_\_code_mode"}. | +| Agent | Verify registration | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Codex | Run `codex mcp list`, then `codex mcp get caplets`; confirm `caplets attach http://127.0.0.1:5387/` or your configured local daemon URL. | +| Claude | Run `claude mcp list`, then `claude mcp get caplets`; confirm the same daemon-backed attach command shape. | +| Other MCP clients | Inspect the client MCP JSON and confirm the `caplets` server command and args from [Install](/install/#manual-mcp-setup). | +| OpenCode / Pi native | Confirm the native integration is using daemon defaults, explicit `mode: "daemon"`, `CAPLETS_MODE`, or remote values and exposes {"caplets\_\_code_mode"}. | ### Wrong config is loading @@ -64,6 +64,34 @@ CAPLETS_CONFIG=/path/to/caplets.json caplets doctor Project config should live at `.caplets/config.json`. User config is for personal agent setup; project config is for capabilities that should travel with a repository. +### Serve defaults are not applying + +Expected symptom: `caplets serve --transport http` or `caplets daemon restart` still uses a +different host, port, path, auth mode, or public origin than the `serve` block you edited. + +Check the active user config path first: + +```sh +caplets config path +``` + +The top-level `serve` block is respected only from user config. `.caplets/config.json` +cannot control local HTTP serving and is ignored with a warning for security. Command flags +and environment variables also win over user config, so remove `--host`, `--port`, +`CAPLETS_SERVER_URL`, or related overrides before expecting defaults to apply. + +For daemon changes, run: + +```sh +caplets daemon restart +caplets daemon status --json +``` + +The restart re-resolves user `serve` defaults for daemon fields that were not explicitly +set at install time. If `caplets setup` still writes a loopback attach URL, that is +intentional: local setup keeps the onboarding daemon credential-free and loopback-only even +when your user defaults are broader. + ### Disable or inspect telemetry Expected symptom: you want to disable anonymous telemetry, inspect local telemetry state, or see the diff --git a/apps/landing/public/config.schema.json b/apps/landing/public/config.schema.json index 46cbc33b..cad9d03a 100644 --- a/apps/landing/public/config.schema.json +++ b/apps/landing/public/config.schema.json @@ -33,6 +33,54 @@ "description": "Set false to disable anonymous Caplets telemetry for this user config.", "type": "boolean" }, + "serve": { + "description": "User-owned HTTP serve defaults. Ignored from project config for security.", + "type": "object", + "properties": { + "host": { + "description": "Default HTTP bind host for caplets serve.", + "type": "string", + "minLength": 1 + }, + "port": { + "description": "Default HTTP port.", + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "path": { + "description": "Default HTTP base path.", + "type": "string" + }, + "remoteStatePath": { + "description": "Default remote credential state directory for HTTP serve.", + "type": "string", + "minLength": 1 + }, + "upstreamUrl": { + "description": "Default upstream Caplets URL for stacked HTTP serve.", + "type": "string" + }, + "allowUnauthenticatedHttp": { + "description": "Opt in to unauthenticated HTTP serving; intended only for trusted local use.", + "type": "boolean" + }, + "trustProxy": { + "description": "Trust proxy headers when deriving public HTTP request URLs.", + "type": "boolean" + }, + "publicOrigins": { + "description": "Additional public HTTP origins.", + "type": "array", + "items": { + "type": "string", + "pattern": "^https?:\\/\\/(?![^/?#]*@)[^/?#]+\\/?$", + "description": "Public HTTP(S) origin for DNS rebinding and credential audience checks." + } + } + }, + "additionalProperties": false + }, "completion": { "default": { "discoveryTimeoutMs": 750, diff --git a/apps/landing/src/data/landing.ts b/apps/landing/src/data/landing.ts index a6b64e7e..994d05e2 100644 --- a/apps/landing/src/data/landing.ts +++ b/apps/landing/src/data/landing.ts @@ -4,7 +4,7 @@ export const heroCommands = [ command: "npm install -g caplets", }, { - label: "Wire up your agent", + label: "Wire up your agent through the local daemon", command: "caplets setup", }, ] as const; @@ -86,7 +86,7 @@ export const whyCapletsProblems = [ { label: "Too much setup", before: "Every agent repeats provider wiring, OAuth, secrets, and MCP config.", - after: "One Caplets surface can be reused locally or from a remote server.", + after: "One daemon-backed Caplets surface can be reused locally or from a remote server.", }, ] as const; diff --git a/docs/architecture.md b/docs/architecture.md index 07870903..eb4af7fc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,6 +56,8 @@ The HTTP server in `packages/core/src/serve/http.ts` exposes versioned MCP, atta `packages/core/src/daemon/` owns the default per-user daemon lifecycle. `caplets daemon install` persists HTTP `caplets serve` configuration, explicit service environment variables, optional shell inheritance intent, user-only log paths, and native service descriptors under the `daemon/default` identity. Runtime lifecycle commands (`start`, `restart`, `stop`, `status`, `logs`, and `uninstall`) read that installed service state instead of accepting serve flags. +Top-level user `serve` config supplies optional HTTP defaults for foreground serve and daemon-managed serve. CLI flags and environment variables win over user config, and explicit daemon install settings win over later user-default changes. `caplets daemon restart` re-resolves user `serve` defaults for fields that were not explicit in the installed daemon config. Project config strips `serve` because repositories must not control a developer's local bind address, auth posture, or public origins. + The daemon uses the native per-user service manager for the host platform: launchd UserAgents on macOS, `systemd --user` services on Linux, and current-user Windows Scheduled Tasks on Windows. There is no detached-process fallback when a native manager is unavailable. Foreground `caplets serve` remains stdio/HTTP serving only. ### Code Mode diff --git a/docs/native-integrations.md b/docs/native-integrations.md index 58903727..d8f29f3f 100644 --- a/docs/native-integrations.md +++ b/docs/native-integrations.md @@ -12,14 +12,17 @@ Auto or configured hosted behavior is lazy. The native integration can start loc OpenCode and Pi use the same resolver as `caplets attach`. -- `CAPLETS_MODE=local` exposes local/user/project Caplets only. +- `CAPLETS_MODE=local` exposes local/user/project Caplets in-process only. +- `CAPLETS_MODE=daemon` requires a loopback daemon URL from explicit config, `CAPLETS_DAEMON_URL`, or setup-written native defaults, and connects without Remote Profile credentials. - `CAPLETS_MODE=remote` requires `CAPLETS_REMOTE_URL` and connects to a self-hosted Caplets service. - `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. +- `CAPLETS_MODE=auto` treats Cloud URLs as Cloud, non-Cloud remote URLs as self-hosted, setup-written daemon defaults as daemon mode, and no remote URL/default as local. + +`caplets setup opencode` and `caplets setup pi` write a non-secret native defaults file with the local daemon URL after the daemon is healthy. Explicit integration config wins first, Pi settings win next, runtime environment selectors such as `CAPLETS_MODE`, `CAPLETS_DAEMON_URL`, and `CAPLETS_REMOTE_URL` override setup-written native defaults, and malformed defaults are ignored with a warning. 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. Upstream file propagation uses Mutagen after sync filters and size limits are translated into an enforceable policy. If the upstream binding path is unavailable or quarantined, 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. `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. +`caplets attach`, native daemon mode, and native remote integrations connect to the `/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-30-001-feat-daemon-first-setup-onboarding-plan.md b/docs/plans/2026-06-30-001-feat-daemon-first-setup-onboarding-plan.md new file mode 100644 index 00000000..041e3724 --- /dev/null +++ b/docs/plans/2026-06-30-001-feat-daemon-first-setup-onboarding-plan.md @@ -0,0 +1,460 @@ +--- +title: Daemon-First Setup Onboarding - Plan +type: feat +date: 2026-06-30 +topic: daemon-first-setup-onboarding +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-brainstorm +execution: code +--- + +# Daemon-First Setup Onboarding - Plan + +## Goal Capsule + +- **Objective:** Make `caplets setup` the daemon-first onboarding path so users get a stable local runtime before any agent integration is configured. +- **Product authority:** The Product Contract is authoritative for user-facing behavior; the Planning Contract is authoritative for implementation seams, sequencing, and verification. +- **Execution profile:** Cross-interface CLI/native/docs work with an external package dependency; implement characterization coverage before changing current setup and attach behavior. +- **Stop conditions:** Stop and re-plan if the pinned `add-mcp` contract check fails, if local daemon attach cannot be made credential-free without weakening remote credential boundaries, or if native daemon defaults require a user-visible product change beyond the Product Contract. +- **Tail ownership:** The implementer owns tests, docs, generated checks, a changeset, and cleanup of any superseded `caplets serve` setup copy introduced by this plan. + +--- + +## Product Contract + +### Summary + +`caplets setup` should initialize a user-owned local runtime first, then point agent integrations at that runtime. +MCP clients should be configured through a Caplets-owned picker backed by `add-mcp`'s programmatic server-add interface, while native integrations keep their plugin setup flows and receive the local daemon URL. + +### Problem Frame + +The current local MCP setup path launches `caplets serve` inside each MCP client process. +Some MCP clients do not pass through the agent parent's environment, so Caplets can start without the environment needed by configured backends. +A daemon-backed setup moves backend execution into the per-user Caplets service, leaving `caplets attach` as a thin client wrapper that talks to the daemon. + +### Key Decisions + +- **Daemon-first is the default setup posture.** The happy path prioritizes a healthy local daemon over foreground `serve` because the daemon owns the user's full runtime environment. +- **User config is the first-run config target.** `caplets setup` creates the user config if missing, not a project config, because the default daemon is a per-user service. +- **Caplets owns the MCP picker.** Caplets controls the onboarding copy, daemon framing, and confirmation flow while using `add-mcp` for supported-client metadata and config mutation. +- **Use `add-mcp` programmatically, not via unpinned `npx`.** The integration should be a controlled adapter around the server-add/upsert interface so Caplets can pin behavior, surface warnings, and avoid remote package execution during setup. +- **Fail before agent config when the daemon is unhealthy.** Setup should not leave agents pointed at a daemon URL that cannot currently serve Caplets. +- **Public docs move toward daemon-backed attach.** First-run and local MCP docs should teach the daemon path; foreground `caplets serve` remains documented as an advanced/manual fallback. + +### Actors + +- A1. Human installer running `caplets setup`. +- A2. MCP client that starts a configured Caplets stdio command. +- A3. Native integration such as OpenCode or Pi. +- A4. Local Caplets Daemon that owns backend execution and environment. +- A5. `add-mcp` adapter used by Caplets to mutate MCP client configuration. + +### Key Flow + +```mermaid +flowchart TB + A[User runs caplets setup] --> B[Ensure user config] + B --> C[Install or start local daemon] + C --> D{Daemon healthy?} + D -->|no| E[Stop before agent config] + D -->|yes| F[Resolve local daemon URL] + F --> G[Caplets MCP picker] + G --> H[add-mcp adapter writes selected MCP client configs] + F --> I[Native plugin setup keeps existing flow] + I --> J[Native integration receives daemon URL] + H --> K[Agent runs caplets attach local daemon URL] + J --> L[Native integration talks to daemon-backed runtime] +``` + +- F1. First-run daemon-first setup + - **Trigger:** A1 runs `caplets setup` without an explicit remote/cloud setup intent. + - **Actors:** A1, A2, A3, A4, A5 + - **Steps:** Setup creates the user config if missing, installs or starts the local daemon, verifies health, resolves the local daemon URL, shows the Caplets-owned MCP picker, configures selected MCP clients through the `add-mcp` adapter, and sets up native integrations through their plugin systems with the daemon URL. + - **Outcome:** Agents launch thin attach/native clients while backend execution happens in A4. + - **Covered by:** R1, R2, R3, R5, R6, R10, R11, R14, R15, R16 +- F2. Daemon failure before integration mutation + - **Trigger:** Setup cannot install, start, or health-check the local daemon. + - **Actors:** A1, A4 + - **Steps:** Setup reports the daemon failure and recovery guidance before calling the MCP config writer or native integration setup. + - **Outcome:** No new agent config points at an unavailable daemon. + - **Covered by:** R4 + +### Requirements + +**Daemon-first setup lifecycle** + +- R1. `caplets setup` must create the user Caplets config when it is missing and must not create a project config by default. +- R2. `caplets setup` must install, update, start, or reuse the default local daemon before configuring any agent integration. +- R3. MCP client configs created by setup must run `caplets attach ` rather than `caplets serve`. +- R4. Setup must fail before mutating agent integration config when user config initialization, daemon installation, daemon startup, or daemon health verification fails. +- R5. Setup output must make the daemon-backed shape visible enough that users understand the agent command is a thin client and the daemon is the runtime. + +**MCP client configuration** + +- R6. Caplets must use `add-mcp` through a controlled programmatic adapter for MCP client config mutation, not through an unpinned `npx add-mcp` shell command. +- R7. The MCP picker must be Caplets-owned and show detected supported clients first, with an option to show all clients supported by the `add-mcp` adapter. +- R8. Targeted setup must still allow users to choose a specific supported MCP client without going through broad auto-detection. +- R9. Setup must support every MCP client that the adopted `add-mcp` adapter can configure for a stdio server command. +- R10. Setup must not auto-configure every detected or globally supported client without an explicit user selection. +- R11. Setup must surface `add-mcp` warnings and partial failures in Caplets language, including unsupported fields or client-specific limitations. +- R12. Setup must not put Caplets secrets, remote tokens, Vault values, or environment-bearing credential material into MCP client config. +- R13. The existing generic MCP config output path may remain as an advanced fallback, but it is not the daemon-first happy path. + +**Native integration configuration** + +- R14. OpenCode and Pi setup must continue to use their existing plugin installation systems. +- R15. Native setup must configure the integration's local daemon URL option so the integration talks to the daemon-backed runtime by default. +- R16. Native setup failure must not be hidden behind successful daemon setup; the final output must distinguish daemon readiness from integration readiness. + +**Public-facing documentation** + +- R17. `README.md` and public docs under `apps/docs/` must present daemon-first `caplets setup` as the local onboarding path. +- R18. Public docs must demote foreground `caplets serve` to manual, debugging, or advanced MCP setup rather than first-run local setup. +- R19. Docs must explain that daemon-backed setup avoids MCP client environment passthrough problems because backend execution happens in the Caplets Daemon. +- R20. Docs must keep remote/cloud setup separate from the local daemon-first path and preserve the rule that remote credentials stay in Caplets-owned stores rather than agent config. + +**Compatibility and operator control** + +- R21. Dry-run and JSON setup output must describe the full daemon-first plan without performing hidden side effects. +- R22. Existing users with foreground `serve` configs must have a clear migration or repair path to daemon-backed attach without losing the ability to run `serve` manually. +- R23. Setup must provide actionable recovery guidance when daemon setup succeeds but an integration-specific config mutation fails. + +### Acceptance Examples + +- AE1. **Covers R1, R2, R3, R6, R7.** Given no user config exists and no daemon is running, when A1 runs interactive `caplets setup` and selects a supported MCP client, then setup creates the user config, starts a healthy daemon, and writes an MCP client config that launches `caplets attach `. +- AE2. **Covers R4, R10.** Given daemon startup fails, when A1 runs setup, then setup reports the daemon failure and does not call the MCP config adapter or mutate native integration config. +- AE3. **Covers R3, R5, R19.** Given an MCP client does not pass through the agent parent's environment, when it starts the configured Caplets command, then the command acts as an attach wrapper and backend execution uses the daemon environment. +- AE4. **Covers R7, R8, R9, R10.** Given `add-mcp` supports a client that Caplets did not previously hard-code, when A1 chooses that client from “all supported clients,” then setup configures only that selected client. +- AE5. **Covers R14, R15, R16.** Given A1 selects a native integration, when setup completes, then the plugin setup remains the integration's normal install flow and the integration points at the local daemon URL. +- AE6. **Covers R12, R20.** Given a remote or credential-bearing Caplet environment exists locally, when setup writes an MCP client config, then the config contains no Caplets remote token, Vault value, or credential env var. +- AE7. **Covers R17, R18, R19.** Given a first-time user reads the README or docs site, when they follow local setup guidance, then they are directed to daemon-first setup and see foreground `serve` only as an advanced/manual option. + +### Success Criteria + +- The local setup happy path no longer depends on MCP clients inheriting the agent parent's environment. +- A user can configure any MCP client supported by the adopted `add-mcp` adapter through Caplets setup. +- Public docs consistently teach daemon-backed attach for local first-run setup. +- Remote-secret boundaries remain intact: agent config contains stable selectors, not Caplets-owned credentials. + +### Scope Boundaries + +- Remote and Cloud onboarding are not redesigned by this v1 local setup change. +- Project config creation is not part of default `caplets setup`. +- Daemon customization flags, service-manager redesign, and daemon install-time service semantics are left to existing daemon commands and later planning. +- Foreground `caplets serve` is not removed. +- Native plugin setup systems are not replaced by `add-mcp`. +- `add-mcp` registry/search mode is not part of Caplets setup. +- Broad automatic installation into every detected or supported MCP client is not included. + +### Planning Questions Resolved + +- Pin `add-mcp` as a runtime dependency of `@caplets/core` and call its public add-server/upsert interface from a Caplets adapter rather than shelling out to `npx`. +- Resolve the local daemon URL from the installed daemon's serve config and pass the daemon base URL to `caplets attach `. +- Add a first-class native daemon mode with a `daemon.url` option and a Caplets-owned native default store for setup to update safely. +- Keep the generic MCP config output path as an advanced/manual fallback while making the picker-backed `add-mcp` path the local setup happy path. + +### Dependencies / Assumptions + +- `add-mcp@1.13.0` exposes the public programmatic API shape observed during planning: supported agent metadata, project/global detection, and `upsertServer`-style add-server mutation returning structured success, path, warning, and error fields. +- The daemon's persisted serve config remains sufficient to derive a loopback base URL for the default daemon instance. +- OpenCode and Pi can consume a shared Caplets-owned native default store without breaking explicit plugin config, Pi settings, or environment-variable overrides. + +### Sources / Research + +- `STRATEGY.md` frames dependable local, remote, Cloud, and native agent surfaces as a product track. +- `CONCEPTS.md` defines the Caplets Daemon as the per-user native service running local HTTP `caplets serve`. +- `packages/core/src/cli/setup.ts` contains the current integration-focused setup implementation and hard-coded MCP client command shapes. +- `packages/core/src/cli/init.ts` contains current user/project config initialization behavior. +- `packages/core/src/daemon/index.ts` contains current daemon install/start health-check behavior. +- `packages/core/src/remote/selection.ts` currently treats `caplets attach` as remote-only, which is the key seam for daemon-backed local attach. +- `packages/core/src/native/options.ts`, `packages/opencode/src/index.ts`, and `packages/pi/src/index.ts` contain the native mode and settings shapes that need daemon-mode support. +- `README.md`, `apps/docs/src/content/docs/install.mdx`, and `apps/docs/src/content/docs/agent-integrations.mdx` contain current public local setup guidance. +- `docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md` and `docs/plans/2026-06-22-001-feat-self-hosted-pending-remote-login-plan.md` establish prior `add-mcp` and no-secret-agent-config direction. +- External evidence checked `add-mcp@1.13.0`, the `neon-solutions/add-mcp` repository, and npm `npx` behavior documentation during the brainstorm and planning research. + +--- + +## Planning Contract + +### Product Contract Preservation + +Product Contract unchanged except that planning-owned questions were resolved into the `Planning Questions Resolved` subsection. +No requirement, actor, flow, acceptance example, or success criterion changed. + +### Key Technical Decisions + +- KTD1. **Treat local daemon attach as first-class, not as remote login.** `caplets attach ` must recognize loopback daemon URLs as a credential-free local daemon selection and must not route through Remote Profiles. This preserves the user's thin-client mental model while keeping self-hosted remote and Cloud attach credential checks intact. +- KTD1a. **Make the local daemon trust boundary explicit.** Setup-generated configs may only use the Caplets-owned default daemon URL after reading daemon status/config and passing a health probe. Manual loopback attach is explicitly a same-user loopback trust boundary; it does not claim to defend against malicious local processes that can bind or race loopback ports. +- KTD2. **Derive one canonical daemon client URL from daemon config.** Setup should use the installed daemon's serve host, port, and base path to derive a client-facing base URL, then rely on existing base-path helpers to reach health, MCP, attach, and control endpoints. If the daemon bind host is wildcard, setup may canonicalize to loopback only after a loopback health probe succeeds; if the daemon is bound to a non-loopback host, setup fails before agent mutation with recovery guidance. The value written into agent config is the base URL, not a `/v1/attach` endpoint, matching existing `caplets attach ` behavior for remotes. +- KTD3. **Compose setup from existing config and daemon primitives.** The daemon-first setup path should call existing config initialization, daemon install/start/status, and daemon health helpers rather than duplicating service-manager behavior in the CLI setup module. +- KTD4. **Validate and wrap `add-mcp` behind a Caplets adapter.** Add `add-mcp@1.13.0` to `@caplets/core` runtime dependencies, prove its public API shape with a non-mutating contract check before broad setup refactors, and expose a small internal adapter for supported-client metadata, detection, and `upsertServer`-style mutation. The rest of setup should depend on Caplets result types so future `add-mcp` churn is localized. +- KTD5. **Caplets owns selection; `add-mcp` owns client-specific writes.** Interactive setup lists detected supported MCP clients first and allows the user to reveal all supported clients. Setup never configures all detected clients unless the user explicitly selects them. +- KTD6. **Native daemon mode is explicit and separate from remote/cloud mode.** Add native `mode: "daemon"` with `daemon.url`, and keep `remote` for self-hosted remote or Cloud selectors that require Remote Profiles. Native option precedence is explicit programmatic/plugin config, then Pi settings where applicable, then environment selectors, then the Caplets native defaults store, then local in-process fallback. This prevents local daemon traffic from inheriting remote-auth recovery text or credential refresh behavior. +- KTD7. **Native setup writes Caplets-owned defaults rather than third-party agent config when possible.** Setup should install native plugins through the existing OpenCode/Pi plugin commands, then update `native-defaults.json` under the user Caplets config root, with a test-only path override. The store is non-secret, versioned, contains the daemon URL plus update metadata, and is ignored with a warning if malformed. Explicit plugin config, Pi settings, and environment variables continue to override this default. +- KTD8. **Partial integration failures are reported, not rolled back.** After config and daemon setup succeed, a client-specific failure should leave the daemon running and report per-integration status plus recovery commands. Setup must not silently undo a healthy daemon because an individual agent config write failed. +- KTD9. **Remote/cloud setup remains separate.** Explicit `--remote`, `--remote-url`, `--server-url`, or remote mode selectors keep using the existing remote setup and Remote Login model. Daemon-first setup only owns the local happy path. + +### High-Level Technical Design + +#### Daemon-first setup lifecycle + +```mermaid +flowchart TB + Start[caplets setup local path] --> Config{User config exists and parses?} + Config -->|missing| Init[Create user config] + Config -->|invalid| ConfigFail[Fail before daemon or agent changes] + Config -->|valid| DaemonStatus[Read daemon status] + Init --> DaemonStatus + DaemonStatus --> Installed{Default daemon installed?} + Installed -->|no| InstallStart[Install daemon with start] + Installed -->|yes, stopped| StartDaemon[Start installed daemon] + Installed -->|yes, running| Probe[Probe daemon health] + InstallStart --> Probe + StartDaemon --> Probe + Probe --> Healthy{Healthy?} + Healthy -->|no| DaemonFail[Fail before integration changes] + Healthy -->|yes| Select[Resolve daemon URL and collect selected integrations] + Select --> Mcp[Write selected MCP clients through add-mcp adapter] + Select --> Native[Install native plugin and write native daemon default] + Mcp --> Report[Plain or JSON phase report] + Native --> Report +``` + +#### Runtime selection after setup + +```mermaid +flowchart TB + McpClient[MCP client starts configured command] --> Attach[caplets attach local daemon URL] + Attach --> LocalSelection[credential-free local daemon selection] + LocalSelection --> DaemonAttach[daemon /v1/attach API] + NativeAgent[OpenCode or Pi native plugin] --> NativeMode[native mode daemon] + NativeMode --> DaemonAttach + RemoteAttach[caplets attach remote or Cloud URL] --> RemoteSelection[Remote Profile or Cloud Auth selection] + RemoteSelection --> RemoteAttachApi[remote /v1/attach API] +``` + +### Sequencing + +1. Run U3 first to pin and verify the `add-mcp` public contract in a non-mutating temp config. +2. Implement the local daemon URL and attach selection seam, because both MCP setup and native daemon mode depend on it. +3. Add daemon-first setup orchestration with dependency-injected daemon and config operations so behavior can be tested without installing a real service. +4. Add the `add-mcp` adapter and picker once setup can provide a healthy local daemon URL and U3 has proven the dependency contract. +5. Add native daemon mode and the shared native defaults store, then wire native setup to that store. +6. Update docs, docs tests, generated references, and release metadata after behavior and output shapes are stable. + +### Assumptions + +- The installed default daemon is the only local daemon instance setup needs to target in v1. +- User/global config creation can use the same starter config semantics as `caplets init --global`; setup should not force-overwrite an existing config. +- The native defaults store is acceptable because it is Caplets-owned, non-secret, validated on read, repairable by rerunning setup, and lower precedence than explicit integration configuration or environment selectors. +- Existing generic `mcp-client --output` users can be served by keeping the manual config path and changing its local command to daemon-backed attach when a daemon URL is available. + +### Risks & Mitigations + +- **`add-mcp` API churn:** Wrap all imports and result interpretation in one adapter, pin the dependency, require a real non-mutating contract test, and use fake adapters for broad setup behavior. +- **Credential boundary regression:** Add tests proving loopback daemon attach bypasses Remote Profiles while non-loopback remotes still require Remote Login or Cloud Auth. +- **Daemon side effects in tests:** Inject config, daemon, prompt, and `add-mcp` adapters into setup tests; reserve real daemon lifecycle coverage for existing daemon tests. +- **Native precedence confusion:** Document and test precedence in native packages: explicit plugin/programmatic config first, integration settings next, Caplets native defaults next, environment/local fallback last according to existing resolver rules. +- **Partial setup confusion:** Use structured phase statuses in JSON and concise plain output so users can tell daemon readiness apart from individual integration readiness. + +### System-Wide Impact + +This change affects CLI setup, attach runtime selection, native integration selection, docs, public onboarding copy, setup telemetry shape, and package dependencies. +It does not alter Caplet config semantics, remote credential storage, Cloud Auth, or daemon service-manager installation semantics outside setup's orchestration of existing daemon commands. + +--- + +## Implementation Units + +### U1. Add local daemon endpoint and attach selection support + +- **Goal:** Make a default daemon base URL discoverable and make `caplets attach ` resolve to a credential-free local daemon selection instead of the remote login path. +- **Requirements:** R2, R3, R5; supports F1, AE1, AE3. +- **Dependencies:** None. +- **Files:** `packages/core/src/daemon/index.ts`, `packages/core/src/daemon/types.ts`, `packages/core/src/daemon/validation.ts`, `packages/core/src/server/options.ts`, `packages/core/src/remote/selection.ts`, `packages/core/src/attach/options.ts`, `packages/core/src/attach/server.ts`, `packages/core/test/serve-daemon.test.ts`, `packages/core/test/remote-selection.test.ts`, `packages/core/test/attach-cli.test.ts`, `packages/core/test/attach-service-wiring.test.ts`. +- **Approach:** Add a daemon URL helper that derives a client-facing base URL from the default daemon serve config and reuses existing base-path helpers for health and attach endpoints. Wildcard daemon binds may be rewritten to loopback only after a loopback health probe succeeds; non-loopback daemon binds fail before agent mutation. Extend remote selection with a local daemon selection for loopback HTTP daemon URLs, leaving non-loopback HTTP and HTTPS remote/cloud URLs on the existing credentialed path. Teach attach service wiring to create a daemon-backed remote client for the local selection without Remote Profile lookup. +- **Execution note:** Start with characterization tests for current remote-only attach failure and existing remote credential enforcement, then add the local daemon cases. +- **Patterns to follow:** `packages/core/src/server/options.ts` for URL derivation and loopback validation; `packages/core/src/remote/options.ts` for base URL to attach URL conversion; `packages/core/test/remote-selection.test.ts` for resolver contract tests. +- **Test scenarios:** + - Covers AE1. Given a daemon config with loopback host, port, and a non-root path, resolving the daemon URL returns a base URL whose attach and health URLs preserve the base path. + - Given a daemon config bound to a wildcard host, setup canonicalizes to a loopback client URL only after a loopback health probe succeeds. + - Given a daemon config bound to a non-loopback host, setup fails before agent mutation with recovery guidance. + - Given setup generates a local attach config, the URL comes from Caplets-owned daemon status/config plus a health probe, not from arbitrary client detection or user-entered loopback text. + - Covers AE3. Given `caplets attach `, attach resolution returns a local daemon selection and does not require a Remote Profile. + - Given `caplets attach https://caplets.example.test/caplets`, attach resolution still uses Remote Login or Cloud Auth and does not use the local daemon bypass. + - Given a non-loopback unauthenticated HTTP URL, attach resolution keeps the existing network safety behavior and does not classify it as local daemon. + - Given `--once` with a remote URL, Project Binding validation behavior remains unchanged. +- **Verification:** Local daemon attach can be resolved without credentials only for loopback daemon URLs, and all existing remote attach auth tests still pass. + +### U2. Orchestrate daemon-first setup phases + +- **Goal:** Change local `caplets setup` from integration-only setup to a phase-based flow that validates or creates user config, installs/starts/reuses the default daemon, health-checks it, and only then configures selected integrations. +- **Requirements:** R1, R2, R4, R5, R16, R21, R23; supports F1, F2, AE1, AE2. +- **Dependencies:** U1. +- **Files:** `packages/core/src/cli/setup.ts`, `packages/core/src/cli.ts`, `packages/core/src/cli/init.ts`, `packages/core/src/daemon/index.ts`, `packages/core/test/setup-runner.test.ts`, `packages/core/test/cli.test.ts`, `packages/core/test/serve-daemon.test.ts`, `packages/core/test/telemetry-runtime.test.ts`. +- **Approach:** Refactor setup around injectable phase operations and structured phase results. The local path ensures user config first, then runs daemon install/start/status/health operations, then passes the resolved daemon URL to selected integration handlers. Dry-run and JSON output should include planned config, daemon, and integration phases without side effects. +- **Execution note:** Preserve existing remote setup behavior with characterization coverage before changing local setup defaults. +- **Patterns to follow:** Existing `runSetup` JSON/plain result tests in `packages/core/test/setup-runner.test.ts`; daemon result redaction patterns in `packages/core/src/daemon/index.ts`; CLI error wrapping in `packages/core/test/cli.test.ts`. +- **Test scenarios:** + - Covers AE1. Given no user config exists, local setup creates the user config phase before the daemon phase and records both in JSON output. + - Given an existing valid user config, setup does not overwrite it and proceeds to daemon setup. + - Given an invalid existing user config, setup fails before daemon install/start and before any integration adapter is called. + - Covers AE2. Given daemon install, start, or health verification fails, setup reports the daemon failure with recovery guidance and does not call MCP or native integration writers. + - Given a daemon is already installed, running, and healthy, setup reuses it and reports reuse rather than restarting by default. + - Given `--dry-run`, setup reports config, daemon, and integration actions as planned and performs no writes, service operations, or adapter calls. + - Given `--format json`, setup emits parseable structured phase statuses and no plain progress text on stdout. +- **Verification:** Local setup cannot reach integration mutation unless config and daemon phases succeeded or were planned in dry-run mode. + +### U3. Validate the pinned `add-mcp` contract + +- **Goal:** Prove the adopted `add-mcp` version exposes the public programmatic API that setup depends on before broad setup refactors build on it. +- **Requirements:** R6, R7, R8, R9, R11; supports AE4. +- **Dependencies:** None. +- **Files:** `packages/core/package.json`, `pnpm-lock.yaml`, `packages/core/src/cli/add-mcp-adapter.ts`, `packages/core/test/add-mcp-adapter.test.ts`, `packages/core/test/package-boundaries.test.ts`. +- **Approach:** Add the pinned runtime dependency, create the adapter module, and write a non-mutating contract test that imports the package, reads supported canonical client metadata, runs detection against a temporary config root, and exercises the server-upsert path only against disposable temp files. If the package cannot provide these operations without real user config mutation, stop before continuing broad setup implementation. +- **Patterns to follow:** Existing package-boundary tests for dependency visibility and setup tests that inject temporary directories instead of mutating user state. +- **Test scenarios:** + - Given the pinned dependency is installed, the adapter can import the public API from `add-mcp` without deep imports. + - Given a disposable temp config root, supported-client detection and upsert can run without touching real user or project config. + - Given the package returns an unexpected result shape, the adapter reports a Caplets-owned contract failure before setup orchestration calls it. +- **Verification:** The adapter contract test is required, not optional, and passes before daemon-first setup depends on `add-mcp`. + +### U4. Add the `add-mcp` adapter and Caplets-owned MCP picker + +- **Goal:** Configure MCP clients through a pinned `add-mcp` programmatic adapter while keeping Caplets responsible for onboarding copy, selection, daemon framing, and result reporting. +- **Requirements:** R3, R6, R7, R8, R9, R10, R11, R12, R13, R21, R22, R23; supports F1, AE1, AE4, AE6. +- **Dependencies:** U1, U2, U3. +- **Files:** `packages/core/src/cli.ts`, `packages/core/src/cli/setup.ts`, `packages/core/src/cli/completion.ts`, `packages/core/src/cli/add-mcp-adapter.ts`, `packages/core/test/setup-runner.test.ts`, `packages/core/test/cli.test.ts`, `packages/core/test/cli-completion.test.ts`, `packages/core/test/agent-plugins.test.ts`. +- **Approach:** Add an internal adapter around `add-mcp@1.13.0` that exposes supported client IDs, detection, and a `caplets` server upsert operation for `{ command: "caplets", args: ["attach", daemonBaseUrl] }`. Interactive setup should show detected supported clients first, allow “show all supported clients,” and configure only the user's selected clients. Targeted setup uses `caplets setup mcp-client --client ` for canonical `add-mcp` client IDs, while `caplets setup codex` and `caplets setup claude-code` remain compatibility aliases for the matching MCP clients. `caplets setup mcp-client --output ` remains the advanced manual config fallback, so unknown top-level setup IDs can continue to delegate to Caplet setup instead of being treated as MCP clients. +- **Execution note:** Use fake adapter tests for broad CLI behavior; the real dependency contract is mandatory and belongs to U3 before this unit proceeds. +- **Patterns to follow:** Current setup command-runner injection in `packages/core/src/cli/setup.ts`; setup completion tests in `packages/core/test/cli-completion.test.ts`; adapter contract boundaries from U3. +- **Test scenarios:** + - Covers AE1. Given the user selects a detected MCP client, setup calls the adapter once for that client with server name `caplets` and the daemon-backed attach command. + - Covers AE4. Given a supported client that Caplets did not previously hard-code is selected from the all-supported list, setup configures only that selected client. + - Covers AE6. Given local remote credentials or Vault-related environment values exist, the adapter receives no `env` field and no credential-bearing values in the MCP server config. + - Given multiple detected clients exist, setup does not configure any unselected client. + - Given interactive or dry-run setup is about to mutate a client config, output names the selected client, project/global scope, and target config path before mutation. + - Given the adapter reports an unsupported client ID or an unexpected target path, setup fails that client before mutation and reports recovery guidance. + - Given `add-mcp` returns dropped fields, extra paths, or a client-specific warning, setup surfaces them in Caplets language in plain output and structured JSON. + - Given `add-mcp` fails for one selected client after daemon setup succeeds, setup reports that client as failed, preserves successful client results, and prints recovery guidance. + - Given `caplets setup mcp-client --client `, setup configures exactly that canonical client and completion/help expose the flag. + - Given `caplets setup codex` or `caplets setup claude-code`, compatibility aliases still configure the corresponding MCP clients through the adapter rather than invoking client-specific shell commands. + - Given `caplets setup mcp-client --output`, the advanced fallback remains available and writes a daemon-backed attach config when a local daemon URL is resolved. +- **Verification:** Caplets setup supports every canonical client exposed by the pinned `add-mcp` adapter without broad auto-configuration or secret-bearing MCP config. + +### U5. Add native daemon mode and Caplets-owned native defaults + +- **Goal:** Let OpenCode and Pi native integrations talk to the local daemon runtime by default after setup without replacing their plugin install systems or overloading remote/cloud mode. +- **Requirements:** R14, R15, R16, R20, R21, R23; supports F1, AE5, AE6. +- **Dependencies:** U1, U2. +- **Files:** `packages/core/src/native/options.ts`, `packages/core/src/native/service.ts`, `packages/core/src/native/remote.ts`, `packages/core/src/native.ts`, `packages/core/src/native/user-settings.ts`, `packages/core/test/native-options.test.ts`, `packages/core/test/native-remote.test.ts`, `packages/core/test/native.test.ts`, `packages/opencode/src/index.ts`, `packages/opencode/test/opencode.test.ts`, `packages/pi/src/index.ts`, `packages/pi/test/pi.test.ts`, `packages/core/src/cli/setup.ts`, `packages/core/test/setup-runner.test.ts`. +- **Approach:** Add `mode: "daemon"` with `daemon.url` to the core native options and create `native-defaults.json` under `resolveCapletsRoot(resolveConfigPath())`, with an explicit options/env override for tests. Store shape is versioned JSON containing `daemon.url`, `updatedAt`, and `source: "setup"`; malformed or stale-looking stores warn and fall through instead of crashing native startup. Native packages should merge configuration in this order: explicit programmatic/plugin config, Pi settings where applicable, environment selectors, Caplets native defaults, then local in-process fallback. Daemon mode resolves to the same credential-free local daemon attach client used by `caplets attach `, and rerunning setup repairs the stored daemon URL after daemon host, port, or base-path changes. The existing plugin install commands remain the setup mechanism for OpenCode and Pi. +- **Execution note:** Add native option parser tests before changing service construction so malformed daemon mode fails with the same clarity as malformed remote mode. +- **Patterns to follow:** `packages/core/src/native/options.ts` for mode resolution, `packages/opencode/src/index.ts` for plugin config normalization, and `packages/pi/src/index.ts` for settings validation and warning behavior. +- **Test scenarios:** + - Covers AE5. Given setup selects OpenCode or Pi after daemon health succeeds, setup installs the plugin and writes the Caplets native defaults store with the daemon URL. + - Given native explicit config sets `mode: "daemon"` and `daemon.url`, the native service uses the local daemon attach client and does not require Remote Login. + - Given setup reruns after daemon host, port, or base-path changes, the native defaults store is updated and no stale daemon URL shadows the current daemon config. + - Given the native defaults store is malformed, native startup warns and falls back to explicit, environment, or local behavior rather than crashing. + - Given explicit plugin config or Pi settings select `remote` or `cloud`, those explicit settings override the Caplets native defaults store. + - Given malformed `daemon.url` or non-loopback HTTP where loopback is required, native option parsing fails with a clear request error. + - Given daemon mode cannot connect, native integration status reports daemon connectivity failure rather than remote credential recovery text. + - Given native setup fails after daemon setup succeeds, JSON and plain output distinguish daemon readiness from native integration readiness. +- **Verification:** OpenCode and Pi can be installed through their existing plugin flows and then resolve to daemon-backed runtime without Remote Profile credentials. + +### U6. Preserve compatibility, migration, and output contracts + +- **Goal:** Keep existing setup entry points usable while making daemon-backed attach the local default and giving existing `serve` users a clear repair/migration path. +- **Requirements:** R5, R13, R16, R21, R22, R23; supports AE2, AE6. +- **Dependencies:** U2, U4, U5. +- **Files:** `packages/core/src/cli/setup.ts`, `packages/core/src/cli.ts`, `packages/core/src/cli/completion.ts`, `packages/core/test/cli.test.ts`, `packages/core/test/setup-runner.test.ts`, `packages/core/test/cli-completion.test.ts`, `packages/core/test/agent-plugins.test.ts`. +- **Approach:** Update setup help, completion, interactive prompts, dry-run text, JSON result types, and failure output to describe daemon-first phases. Preserve explicit remote/cloud setup and Caplet setup routing for unknown integration IDs. Treat existing `caplets` server entries as idempotent upserts through `add-mcp`; interactive and dry-run output must show selected client, project/global scope, and target config path before mutation. Where client mutation semantics are unclear, report the adapter path and recovery or backup guidance instead of pretending a migration succeeded. +- **Patterns to follow:** Existing setup output snapshots in `packages/core/test/cli.test.ts` and neutral setup target tests in `packages/core/test/setup-runner.test.ts`. +- **Test scenarios:** + - Given `caplets setup` without stdin, the help/menu describes daemon-first setup, detected/all MCP client selection, native integrations, remote flags, and the advanced manual config fallback. + - Given interactive setup cancellation after daemon success but before integration selection, setup reports daemon ready and no integrations configured rather than a full failure. + - Given a selected client already has a `caplets` MCP server, setup uses adapter upsert semantics and reports updated or unchanged status. + - Given remote setup flags are present, setup does not install/start the local daemon and keeps existing remote attach/Remote Login output. + - Given an unknown integration ID that matches a Caplet setup target, setup still delegates to Caplet setup rather than treating it as an MCP client. + - Given setup output is JSON, all phase statuses are machine-readable and warnings/errors are not interleaved as plain stdout. +- **Verification:** Existing local setup commands route to daemon-first behavior, remote setup remains stable, and help/completion expose the new supported path without removing advanced manual workflows. + +### U7. Update public documentation and docs safety tests + +- **Goal:** Move first-run public guidance toward daemon-first setup while preserving advanced `serve`, remote/cloud, native, and no-secret-agent-config guidance. +- **Requirements:** R17, R18, R19, R20, R22; supports AE3, AE6, AE7. +- **Dependencies:** U1, U2, U4, U5, U6. +- **Files:** `README.md`, `apps/docs/src/content/docs/index.mdx`, `apps/docs/src/content/docs/install.mdx`, `apps/docs/src/content/docs/agent-integrations.mdx`, `apps/docs/src/content/docs/remote-attach.mdx`, `apps/docs/src/content/docs/troubleshooting.mdx`, `docs/native-integrations.md`, `docs/project-binding.md`, `packages/opencode/README.md`, `packages/pi/README.md`, `apps/landing/src/data/landing.ts`, `packages/core/test/agent-plugins.test.ts`, `packages/core/test/cli.test.ts`. +- **Approach:** Teach `caplets setup` as the default local path that creates or reuses user config, starts a healthy daemon, and configures selected clients with `caplets attach `. Demote `caplets serve` to manual/debugging/advanced local runtime guidance. Keep remote/cloud setup docs on Remote Login and secret-free attach, and add a short explanation of the environment passthrough problem that daemon-first setup solves. +- **Patterns to follow:** Existing remote docs safety test in `packages/core/test/cli.test.ts`; docs reference generation under `pnpm docs:check`. +- **Test scenarios:** + - Covers AE7. README and docs site first-run sections direct users to daemon-first `caplets setup` before manual `caplets serve`. + - Covers AE3. Docs explain that backend execution happens in the Caplets Daemon and agent configs are thin attach/native clients. + - Covers AE6. Docs safety tests continue to reject secret-bearing remote env vars, Basic Auth, and `add-mcp --env` guidance. + - Given troubleshooting content mentions agent MCP configs, it tells users to expect `caplets attach ` for daemon-first local setup and reserves `caplets serve` for manual fallback. + - Given native README docs describe mode selection, they include daemon mode and precedence without implying remote credentials belong in plugin config. +- **Verification:** Public docs consistently point local users to daemon-first setup, keep remote credentials out of agent config, and still document manual serve as an advanced fallback. + +### U8. Final verification, generated artifacts, and release metadata + +- **Goal:** Complete repository-level checks, generated-file freshness, and release communication for the daemon-first setup change. +- **Requirements:** All requirements, especially R17-R23. +- **Dependencies:** U1, U2, U3, U4, U5, U6, U7. +- **Files:** `pnpm-lock.yaml`, `.changeset/*.md`, generated docs/schema files if checks require them. +- **Approach:** Refresh lockfile changes from the pinned dependency, run relevant generators when public docs or schemas change, and add a changeset covering the CLI, core, OpenCode, and Pi user-facing changes. The changeset should call out daemon-first setup, `add-mcp`-backed MCP client support, and native daemon mode without presenting remote/cloud as redesigned. +- **Test scenarios:** Test expectation: none -- this unit consolidates verification and release metadata after behavior is covered by U1-U6. +- **Verification:** All targeted and full quality gates listed in the Verification Contract pass, generated checks are clean, and the changeset accurately describes user-facing package impact. + +--- + +## Verification Contract + +| Gate | Command | Applies to | Done signal | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------- | +| Formatting | `pnpm format:check` | All units | No formatting drift. | +| Lint | `pnpm lint` | All units | No lint errors. | +| Core typecheck | `pnpm --filter @caplets/core typecheck` | U1-U6 | Core setup, attach, daemon, and native type changes compile. | +| Native package typecheck/build | `pnpm --filter @caplets/opencode build` and `pnpm --filter @caplets/pi build` | U5, U7 | Native package public config changes build. | +| `add-mcp` adapter contract | `pnpm --filter @caplets/core test -- test/add-mcp-adapter.test.ts` | U3, U4 | Pinned `add-mcp` API shape and non-mutating temp-config behavior pass. | +| Focused setup tests | `pnpm --filter @caplets/core test -- test/setup-runner.test.ts test/cli.test.ts test/cli-completion.test.ts` | U2, U4, U6 | Setup orchestration, output, picker, and completion behavior pass. | +| Focused attach/daemon tests | `pnpm --filter @caplets/core test -- test/serve-daemon.test.ts test/remote-selection.test.ts test/attach-cli.test.ts test/attach-service-wiring.test.ts` | U1 | Local daemon attach and existing remote attach behavior pass. | +| Focused native tests | `pnpm --filter @caplets/core test -- test/native-options.test.ts test/native-remote.test.ts test/native.test.ts` plus package tests for OpenCode and Pi | U5 | Native daemon mode, precedence, and package integration behavior pass. | +| Docs checks | `pnpm docs:check` | U7 | Generated docs references and public docs safety checks are clean. | +| Generated API/schema checks | `pnpm code-mode:check-api` and `pnpm schema:check` | U5 and any config/schema changes | Public generated contracts are current or intentionally unchanged. | +| Full test suite | `pnpm test` | All units | Repo tests pass. | +| Full gate | `pnpm verify` | Before PR/merge | CI-equivalent local gate passes. | + +Behavioral verification must include a dry-run transcript and a JSON transcript for a local setup path with a fake daemon and fake `add-mcp` adapter. +Manual smoke testing should be limited to a disposable config directory and should not mutate the user's real MCP client configs. + +--- + +## Definition of Done + +- `caplets setup` local mode creates or reuses user config, installs/starts/reuses a healthy local daemon, and stops before agent config if config or daemon health fails. +- The pinned `add-mcp` adapter contract is verified against disposable temp config before setup depends on it. +- MCP client setup writes daemon-backed `caplets attach ` commands through the Caplets `add-mcp` adapter for selected clients only. +- The MCP picker shows detected supported clients first and can configure every supported `add-mcp` client through an explicit user selection. +- Remote/cloud setup remains on the existing Remote Login and secret-free attach path. +- OpenCode and Pi support explicit native daemon mode and can consume the repaired-on-rerun daemon URL from the Caplets native defaults store without replacing their plugin install systems. +- Plain and JSON setup output distinguish config, daemon, MCP, native, warning, and failure statuses. +- Public docs and package READMEs teach daemon-first local setup, explain the environment passthrough benefit, and keep `serve` as an advanced/manual fallback. +- No Caplets remote tokens, Vault values, or credential env vars are written into MCP client config, native config, docs, or tests. +- Targeted tests for setup, attach, daemon, native, docs, and package integrations pass. +- `pnpm verify` passes or any skipped full gate has a documented reason and equivalent focused evidence. +- A changeset covers the user-facing CLI/core/native/docs behavior changes. +- Dead-end implementation attempts, temporary test fixtures, and exploratory code are removed from the final diff. + +--- + +## Appendix + +### Research Notes + +- Current local setup hard-codes foreground `caplets serve` for Codex, Claude Code, and generic MCP config in `packages/core/src/cli/setup.ts`. +- Current remote setup already uses `caplets attach ` for Codex, Claude Code, and generic MCP config, which is the command shape daemon-first local setup should reuse. +- `caplets attach` currently fails in local mode through `packages/core/src/remote/selection.ts`; daemon-first setup needs a local daemon selection rather than treating loopback daemon URLs as credentialed remotes. +- Daemon lifecycle helpers already install, start, restart, status-check, and health-check the service in `packages/core/src/daemon/index.ts`; setup should orchestrate them instead of recreating daemon semantics. +- `add-mcp@1.13.0` exposes public programmatic client detection and server upsert behavior; shelling out to `npx add-mcp` would add remote package execution and version drift to first-run setup. diff --git a/docs/plans/2026-06-30-002-feat-global-serve-config-defaults-plan.md b/docs/plans/2026-06-30-002-feat-global-serve-config-defaults-plan.md new file mode 100644 index 00000000..66027254 --- /dev/null +++ b/docs/plans/2026-06-30-002-feat-global-serve-config-defaults-plan.md @@ -0,0 +1,449 @@ +--- +title: Global Serve Config Defaults - Plan +type: feat +date: 2026-06-30 +topic: global-serve-config-defaults +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-brainstorm +execution: code +plan_depth: standard +--- + +# Global Serve Config Defaults - Plan + +## Goal Capsule + +- **Objective:** Add global-only serve defaults so users can keep durable HTTP serve settings in Caplets config without letting project config change server bind, auth, or public-origin behavior. +- **Product authority:** Product Contract unchanged. This Product Contract owns the user-visible config semantics, precedence rules, project-config boundary, and public-origin behavior. Planning owns code structure, schema placement, and exact warning plumbing. +- **Open blockers:** None. Implementation must preserve daemon-first local onboarding safety while allowing daemon-managed HTTP serve processes to pick up global defaults on restart when fields are not explicitly set by the daemon command. + +--- + +## Product Contract + +### Summary + +Add a top-level global `serve` config block for durable HTTP serve defaults. `caplets serve --transport http` and daemon-managed `caplets serve` read these defaults at process start, while project config `serve` entries are warned and ignored. + +### Problem Frame + +Serve behavior is currently controlled through command flags and a small set of environment variables. That works for one-off commands, but it makes stable local daemon and HTTP runtime preferences hard to keep in user-owned configuration. + +The security boundary matters because project config is shareable repository state. A project must not be able to make a user's Caplets process bind to a different host, open unauthenticated HTTP, change public-origin identity, or alter remote credential state. + +### Key Decisions + +- **Global config supplies defaults, not authority.** CLI flags win first, existing environment-derived settings win second, global config fills only the remaining defaults, and built-in defaults stay last. +- **Transport remains command-only.** Global config cannot make bare `caplets serve` switch from stdio to HTTP, and HTTP-only serve defaults apply only when HTTP transport is requested by the command surface. +- **Use public-origin semantics instead of host-only naming.** The confirmed shape is `serve.publicOrigins`, not `serve.allowedHosts`, because entries participate in public host/origin identity rather than a broad network ACL. +- **Daemon-managed serve reads current global config on restart.** Editing global `serve` config can affect a managed daemon after restart when the daemon command did not explicitly set that field. + +### Actors + +- A1. Local user/operator owns global Caplets config and chooses CLI flags, environment variables, daemon install flags, and daemon restarts. +- A2. Project config author may commit project Caplets config but cannot control serve bind, auth, public-origin, or remote credential state. +- A3. MCP, attach, and remote-login clients interact with the HTTP serve process and depend on stable host, auth, and public-origin behavior. + +### Requirements + +**Global config surface** + +- R1. Global config may define a top-level `serve` block for HTTP serve defaults. +- R2. The `serve` block must support existing HTTP serve defaults for bind host, port, base path, remote credential state path, upstream URL, unauthenticated HTTP opt-in, and trusted-proxy mode. +- R3. The `serve` block must support `publicOrigins` as a list of full origins that are valid public identities for the serve process. +- R4. The config surface must not define `serve.transport`; transport selection remains a command concern. + +**Precedence and resolution** + +- R5. CLI flags override environment-derived values and global `serve` config values. +- R6. Existing environment variables override global `serve` config values. +- R7. Global `serve` config values override built-in serve defaults only when the setting is otherwise unset. +- R8. HTTP-only serve config values must not affect stdio serve and must not make stdio serve fail merely because global HTTP defaults exist. + +**Project config boundary** + +- R9. Project config `serve` entries must never affect resolved serve behavior. +- R10. Project config `serve` entries must produce a user-visible warning that they were ignored for security reasons. +- R11. Ignoring project `serve` must not discard valid project Caplet definitions or project Caplet files. + +**Public-origin behavior** + +- R12. `serve.publicOrigins` entries must be treated as public origins, not as a generic network allowlist. +- R13. Public origins from config may participate in Host-header protection and remote credential audience calculation under the same safety rules as existing explicit public-origin behavior. +- R14. Invalid public-origin values must fail global config validation rather than being silently skipped. + +**Daemon and runtime behavior** + +- R15. Foreground `caplets serve --transport http` must read global `serve` config at process start. +- R16. A daemon-managed `caplets serve` process must read global `serve` config at process start so daemon restarts pick up config changes. +- R17. Explicit daemon install or command-level settings remain higher priority than global config defaults. +- R18. Daemon-first local onboarding must not lose its loopback, credential-free safety guarantees because of global serve defaults. + +### Key Flows + +- F1. Foreground HTTP serve with durable defaults + - **Trigger:** A user runs `caplets serve --transport http` with global `serve` config present. + - **Actors:** A1, A3. + - **Steps:** Serve resolution reads CLI flags, environment values, global config, then built-in defaults. HTTP-only defaults apply because HTTP transport was requested. + - **Outcome:** The HTTP runtime starts with the expected durable defaults without requiring repeated flags. + - **Covered by:** R1-R8, R12-R15. + +- F2. Project config attempts to control serve + - **Trigger:** A project config includes a top-level `serve` block. + - **Actors:** A1, A2. + - **Steps:** Config loading keeps valid project Caplets, ignores project `serve`, and reports that project serve settings are user-owned for security. + - **Outcome:** The project cannot change local bind, auth, public-origin, or credential-state behavior. + - **Covered by:** R9-R11. + +- F3. Daemon restart picks up global serve defaults + - **Trigger:** A user edits global `serve` config and restarts the Caplets Daemon. + - **Actors:** A1, A3. + - **Steps:** The managed serve process starts and resolves current global config for fields not explicitly set by the daemon command. + - **Outcome:** The daemon reflects durable user config without requiring reinstall for defaulted fields. + - **Covered by:** R15-R18. + +### Acceptance Examples + +- AE1. **Covers R4, R8.** Given global config contains HTTP serve defaults, when the user runs bare `caplets serve`, then the command still uses stdio transport and does not fail because HTTP defaults exist. +- AE2. **Covers R5-R7.** Given a serve port exists in CLI flags, environment, and global config, when HTTP serve resolves options, then the CLI value is used. +- AE3. **Covers R6-R7.** Given a serve path exists in environment and global config but no CLI flag, when HTTP serve resolves options, then the environment value is used. +- AE4. **Covers R9-R11.** Given project config contains `serve.allowUnauthenticatedHttp`, when config loads, then serve behavior ignores that value, emits a warning, and still loads valid project Caplets. +- AE5. **Covers R12-R14.** Given global config contains a valid public origin, when HTTP attach or remote credential routes evaluate host identity, then that origin is accepted under the same safety rules as an explicit public origin. +- AE6. **Covers R15-R18.** Given the daemon command did not explicitly set a port and global config changes the port, when the daemon process restarts, then the managed serve process uses the updated configured port. + +### Success Criteria + +- The generated config schema documents global `serve` config and rejects invalid values. +- Focused config tests prove project `serve` warnings and ignored behavior. +- Focused serve tests prove CLI > env > global config > built-in defaults. +- Focused daemon tests prove restart-time config reading without weakening daemon-first local onboarding safety. +- Public docs explain that `serve` config is user/global-only and project config cannot control server exposure. + +### Scope Boundaries + +- Project config cannot control serve behavior in v1. +- This work does not add a network ACL, firewall, or per-client authorization policy. +- This work does not make transport a config preference. +- This work does not rename existing CLI flags. +- This work does not remove environment variable support for existing serve settings. + +### Dependencies / Assumptions + +- Config schema source remains `packages/core/src/config.ts` and generated schema checks remain authoritative. +- Serve option resolution remains the shared path for foreground serve, attach legacy HTTP serving, and daemon-managed serve processes. +- Existing daemon install flags continue to represent explicit command-level intent. + +### Sources / Research + +- `packages/core/src/serve/options.ts` currently defines and resolves HTTP serve options. +- `packages/core/src/cli.ts` currently exposes foreground serve flags and hidden attach legacy HTTP flags. +- `packages/core/src/config.ts` currently has no top-level `serve` property and already applies project-config security filtering. +- `docs/plans/2026-06-19-001-feat-caplets-daemon-service-plan.md` established daemon install-time serve configuration behavior. +- `docs/plans/2026-06-30-001-feat-daemon-first-setup-onboarding-plan.md` established daemon-first setup and local daemon URL derivation constraints. + +--- + +## Planning Contract + +### Product Contract Preservation + +Product Contract unchanged. Planning adds implementation strategy, sequencing, test scope, and risk treatment without changing the brainstorm-owned requirements. + +### Key Technical Decisions + +- **KTD1. Treat `serve` as user-owned runtime configuration.** Add `serve` to the normal config schema and normalized config shape, but strip or ignore it from project-owned config sources before merge using the existing project-config security seam. Project warnings should flow through the same warning paths used by best-effort local runtime loading so user-facing commands can surface them without failing valid project Caplets. +- **KTD2. Centralize HTTP default resolution rather than branching per command.** A single serve-resolution path should accept command options, environment-derived values, and optional global `serve` defaults. CLI options must remain the highest-precedence source, and stdio must keep rejecting explicit HTTP-only command options without failing because global HTTP defaults exist. +- **KTD3. Public origins are full-origin identities.** `serve.publicOrigins` should normalize into one canonical public origin plus zero or more additional accepted origins. The first configured origin is canonical for generated metadata; additional origins only extend the existing explicit-origin safety checks for Host-header protection and remote credential audience calculation. +- **KTD4. Boolean defaults need an explicit false path.** If global config can set a boolean default, the command and environment precedence model must also allow a higher-precedence source to turn that default off. Add or normalize explicit false handling for unauthenticated HTTP and trusted-proxy mode rather than treating those flags as one-way opt-ins once configured. +- **KTD5. Daemon configs must distinguish explicit daemon intent from resolved defaults.** Persist enough metadata to know which daemon serve fields came from explicit daemon-install or setup intent. At start/restart time, resolve explicit daemon settings over current environment and global config, then built-ins; do not bake defaulted global config into the service command as if it were explicit. +- **KTD6. Local setup keeps its safety profile by using explicit daemon overrides.** Daemon-first local setup should continue to install or repair a loopback, credential-free daemon before mutating integrations. Global serve defaults may describe broader HTTP runtimes, but they must not silently turn the default local onboarding daemon into a remote-auth or non-loopback service. + +### High-Level Technical Design + +#### Serve Option Precedence + +```mermaid +flowchart TD + Command[Command options] --> Resolve[Resolve serve options] + Env[Existing environment sources] --> Resolve + Global[Global config serve defaults] --> Resolve + BuiltIn[Built-in defaults] --> Resolve + Resolve --> Stdio[Stdio serve] + Resolve --> Http[HTTP serve] + Stdio --> StdioRule[Ignores global HTTP defaults] + Http --> Runtime[HTTP runtime and host/auth/public-origin checks] +``` + +The resolver applies source precedence per field. HTTP-only defaults are available only after HTTP transport is selected, so a global `serve` block cannot change bare `caplets serve` away from stdio. + +#### Daemon Restart Resolution + +```mermaid +flowchart TD + Install[Daemon install or setup] --> Explicit[Persist explicit daemon serve overrides] + Explicit --> Config[Daemon config] + Restart[Daemon start or restart] --> Current[Read current user config and environment] + Config --> Merge[Resolve effective daemon serve options] + Current --> Merge + Merge --> Command[Build managed serve command] + Command --> Health[Health-check daemon before client mutation] +``` + +The daemon's stored config records explicit command intent separately from resolved runtime defaults. Restart reads the current global config for fields without explicit daemon overrides, then health-checks the resolved daemon before setup writes MCP or native integration state. + +### Assumptions + +- `CAPLETS_SERVER_URL` remains the primary environment-derived public-origin source. The plan does not require adding a broad new environment variable namespace for every serve config field unless implementation finds an existing precedence gap that cannot be expressed otherwise. +- Legacy daemon configs that lack explicitness metadata should be treated conservatively as explicit for fields already persisted. Users can rerun setup or daemon install/repair to opt into restart-time global defaults for those fields. +- `publicOrigins` will validate full origins and reject credentials, paths, queries, fragments, and unsafe non-loopback HTTP origins under the same URL safety rules used for current public server URLs. + +### Alternative Approaches Considered + +- **Put all serve behavior in daemon config.** Rejected because foreground HTTP serve also needs durable defaults and because user config is already the cross-command home for user-owned runtime preferences. +- **Allow project config with lower precedence.** Rejected because even low-precedence project serve settings can surprise users when no global or CLI value is present, and the Product Contract explicitly keeps server exposure user-owned. +- **Use host-only `allowedHosts`.** Rejected because the desired behavior participates in public identity and remote credential audiences, not a generic network ACL. + +### System-Wide Impact + +- **Config and schema:** Adds a new public config surface, generated schema output, and public docs reference content. +- **CLI contracts:** `caplets serve`, hidden attach HTTP compatibility paths, and daemon install/start flows must retain existing flag behavior while accepting global defaults. +- **Daemon lifecycle:** Install, restart, health-check, status, and setup flows must avoid stale default persistence and avoid weakening local setup safety. +- **Security boundary:** Project config filtering and Host-header/remote-credential audience handling are the critical safety surfaces. + +### Risks and Mitigations + +- **Project config changes server exposure.** Mitigate by stripping/ignoring project `serve` before merge, warning visibly, and testing that valid project Caplets still load. +- **Global defaults make local setup require remote auth.** Mitigate by giving setup explicit loopback and unauthenticated-local daemon intent, then health-checking before integration mutation. +- **Daemon restarts keep stale resolved values.** Mitigate by persisting explicitness separately from resolved defaults and testing restart after global config changes. +- **Multiple public origins weaken Host protection.** Mitigate by validating full origins, keeping one canonical generated identity, and limiting additional origins to existing explicit-origin safety paths. +- **Boolean options become impossible to disable.** Mitigate by adding an explicit false override path for higher-precedence sources and covering false-over-true scenarios. + +--- + +## Implementation Units + +### U1. Add global-only serve config + +**Goal:** Add a typed, schema-backed top-level `serve` config surface that is accepted only from user/global config and warned/ignored from project config. + +**Requirements:** R1-R4, R9-R11, R14; supports F2 and AE4. + +**Dependencies:** None. + +**Files:** + +- Modify `packages/core/src/config.ts` +- Modify `packages/core/test/config.test.ts` +- Modify `packages/core/test/config-validation.test.ts` + +**Approach:** Extend the config schema and normalized config output with an optional `serve` object containing HTTP defaults and `publicOrigins`. Reuse existing URL validation helpers where possible, add full-origin validation where the current helpers are too broad, and explicitly exclude `transport`. Extend the project-owned source filtering seam so project `serve` is dropped with a recoverable warning instead of failing valid project config or project Caplet files. + +**Execution note:** Add characterization tests for project config filtering and global schema validation before changing merge behavior. + +**Patterns to follow:** Existing top-level config schema composition in `packages/core/src/config.ts`; project executable backend rejection in `rejectProjectConfigExecutableBackendMaps`; warning assertions in `packages/core/test/config.test.ts`. + +**Test scenarios:** + +- Happy path: global config with host, port, base path, remote credential state path, upstream URL, unauthenticated HTTP, trusted proxy, and public origins parses into normalized config. +- Edge case: global config omits `serve`; existing configs still parse and normalize exactly as before. +- Error path: global config with `serve.transport` is rejected by schema validation. +- Error path: global config with an invalid public origin, path-bearing origin, credentialed origin, query, or fragment fails validation. +- Integration: project config with `serve.allowUnauthenticatedHttp` emits a recoverable warning, ignores the value, and still loads valid project Caplets. +- Integration: user/global `serve` survives merge when project config and project Caplet files are present. +- Covers AE4. Project `serve` cannot affect resolved serve behavior even when no user/global `serve` value exists. + +**Verification:** Config normalization exposes global `serve`; generated schema assets include the global-only surface; project `serve` warnings are recoverable and do not drop valid project Caplets. + +### U2. Apply serve config defaults through shared option resolution + +**Goal:** Make foreground HTTP serve and HTTP-compatible attach paths resolve CLI, environment, global config, and built-in defaults through one precedence model. + +**Requirements:** R1-R8, R12-R15; supports F1 and AE1-AE3. + +**Dependencies:** U1. + +**Files:** + +- Modify `packages/core/src/serve/options.ts` +- Modify `packages/core/src/serve/index.ts` +- Modify `packages/core/src/cli.ts` +- Modify `packages/core/test/serve-options.test.ts` +- Modify `packages/core/test/cli.test.ts` + +**Approach:** Extend serve option resolution to accept optional global serve defaults without treating those defaults as command-specified raw options. Load global config for command surfaces that need HTTP defaults, pass defaults into the resolver, and preserve current stdio behavior by applying HTTP defaults only after transport is resolved as HTTP. Ensure command-level booleans can explicitly override configured booleans in both directions. + +**Execution note:** Start with focused resolver tests for source precedence before wiring the CLI. + +**Patterns to follow:** Current `resolveServeOptions` source ordering for `CAPLETS_SERVER_URL`; CLI serve action in `packages/core/src/cli.ts`; hidden attach HTTP compatibility validation near attach serve option handling. + +**Test scenarios:** + +- Covers AE1. Bare `caplets serve` uses stdio and does not fail when global HTTP serve defaults exist. +- Covers AE2. When CLI, environment, and global config all specify a port, the CLI value wins. +- Covers AE3. When environment and global config specify path but no CLI flag does, the environment value wins. +- Happy path: HTTP serve with only global config uses configured host, port, base path, remote credential state path, upstream URL, unauthenticated HTTP, trusted proxy, and public origins. +- Edge case: global config booleans set to true can be overridden by a higher-precedence explicit false source. +- Error path: explicit HTTP-only command options with stdio still produce the existing HTTP-only option error. +- Integration: hidden attach HTTP serving uses the same HTTP defaults and precedence where it still accepts HTTP serve options. + +**Verification:** Focused serve resolver and CLI tests prove field-level precedence, stdio isolation, boolean override behavior, and no regression in existing HTTP-only option validation. + +### U3. Extend public-origin safety from singular to list semantics + +**Goal:** Support `serve.publicOrigins` as full public origins without turning it into a broad host allowlist or weakening remote credential safety. + +**Requirements:** R3, R12-R14; supports F1, AE5. + +**Dependencies:** U1, U2. + +**Files:** + +- Modify `packages/core/src/serve/options.ts` +- Modify `packages/core/src/serve/http.ts` +- Modify `packages/core/test/serve-options.test.ts` +- Modify `packages/core/test/serve-http.test.ts` + +**Approach:** Normalize configured origins into a canonical public origin plus additional accepted origins. Preserve `CAPLETS_SERVER_URL` compatibility as the existing singular environment-derived origin. Use the canonical origin for generated host identity, stack identity, version metadata, and default callback bases; allow additional origins only in Host-header protection and remote credential audience decisions where the current explicit-origin path already permits the same origin class. + +**Execution note:** Add tests around remote-login and Host-header protection before changing HTTP app behavior. + +**Patterns to follow:** Current `publicOrigin` handling in `remoteCredentialHostUrl`, `remoteHostMetadata`, `httpStackIdentity`, and `dnsRebindingOptions`; existing serve HTTP tests for trusted proxy and DNS rebinding protection. + +**Test scenarios:** + +- Happy path: a single configured public origin behaves like today's explicit public origin. +- Happy path: multiple configured public origins accept requests for each configured host under loopback DNS-rebinding protection. +- Edge case: the first configured origin remains the generated host identity when multiple origins are present. +- Error path: `trustProxy` with remote credential auth still requires a configured public origin or environment-derived equivalent. +- Error path: an unconfigured Host header cannot create pending remote-login state. +- Covers AE5. Remote credential audience calculation accepts configured public origins under the same safety rules as current explicit public-origin behavior. + +**Verification:** HTTP serve tests prove canonical identity generation, additional-origin acceptance, trusted-proxy guardrails, and rejection of unconfigured hosts before remote credential state mutation. + +### U4. Make daemon restart resolve current global defaults + +**Goal:** Let daemon-managed HTTP serve pick up global serve config on restart for fields not explicitly set by daemon install or setup. + +**Requirements:** R15-R18; supports F3 and AE6. + +**Dependencies:** U1, U2, U3. + +**Files:** + +- Modify `packages/core/src/daemon/types.ts` +- Modify `packages/core/src/daemon/process.ts` +- Modify `packages/core/src/daemon/index.ts` +- Modify `packages/core/src/daemon/config.ts` +- Modify `packages/core/src/daemon/validation.ts` +- Modify `packages/core/test/serve-daemon.test.ts` + +**Approach:** Track which daemon serve fields are explicit daemon intent instead of only storing fully resolved HTTP serve options. Build the managed command from explicit daemon settings plus current global config and environment at install/start/restart time. Preserve selected config-path environment in the service environment so the daemon process can read the same user config. Treat legacy daemon configs without explicitness metadata as explicit for already-stored fields to avoid surprising existing installs. + +**Execution note:** Characterize current daemon install/update behavior before introducing explicitness metadata. + +**Patterns to follow:** `resolveDaemonHttpServeOptions`, `daemonServeArgs`, `buildDaemonCommandPlan`, daemon status redaction, and restart/update tests in `packages/core/test/serve-daemon.test.ts`. + +**Test scenarios:** + +- Covers AE6. A daemon installed without an explicit port uses the updated global configured port after restart. +- Happy path: explicit daemon install host, port, path, state path, upstream URL, unauthenticated HTTP, trusted proxy, and public origin override global config. +- Edge case: legacy daemon config without explicitness metadata keeps previously persisted serve values after upgrade. +- Integration: selected config-path environment is preserved in the daemon service environment so the managed process can read global config. +- Error path: daemon install still rejects transport input before writing artifacts. +- Error path: invalid global serve config prevents unsafe daemon resolution rather than writing a partially updated daemon config. +- Integration: status JSON redacts remote credential state paths and does not expose secret material from daemon config or service env. + +**Verification:** Daemon lifecycle tests prove restart-time config reading, explicit daemon override precedence, safe legacy behavior, and unchanged redaction guarantees. + +### U5. Preserve daemon-first setup safety with global defaults present + +**Goal:** Keep default local onboarding loopback and credential-free even when global serve defaults describe a broader HTTP runtime. + +**Requirements:** R17-R18; supports F3 and daemon-first setup acceptance from the prior onboarding plan. + +**Dependencies:** U2, U4. + +**Files:** + +- Modify `packages/core/src/cli/setup.ts` +- Modify `packages/core/src/daemon/index.ts` +- Modify `packages/core/test/setup-runner.test.ts` +- Modify `packages/core/test/serve-daemon.test.ts` + +**Approach:** Ensure local setup supplies explicit daemon intent for loopback host and unauthenticated local HTTP before writing MCP or native integration config. Reuse only daemon instances whose effective URL is loopback and credential-free for local setup; otherwise repair/install or fail before mutation with recovery guidance. Keep remote/cloud setup separate from global local serve defaults. + +**Execution note:** Start with setup-runner tests that place unsafe global serve defaults in user config and prove no integration mutation occurs until a safe daemon is available. + +**Patterns to follow:** Existing `ensureLocalDaemon`, daemon health gating, local daemon URL derivation, and setup phase result reporting in `packages/core/src/cli/setup.ts`. + +**Test scenarios:** + +- Happy path: local setup with global non-loopback host still installs or repairs a loopback credential-free daemon for integration config. +- Edge case: global `allowUnauthenticatedHttp` false does not force local setup into remote credential auth. +- Error path: an existing non-loopback or credentialed daemon is not reused for credential-free local setup before integration mutation. +- Integration: successful setup writes MCP and native integration state only after daemon health succeeds. +- Integration: remote/cloud setup keeps using explicit remote URL selection and does not read global local serve defaults as a remote target. + +**Verification:** Setup tests prove global serve defaults cannot weaken local onboarding safety and that failed/unsafe daemon resolution stops before agent config mutation. + +### U6. Update docs, generated assets, and release metadata + +**Goal:** Document the global-only serve config surface, project-config boundary, daemon restart behavior, public-origin semantics, and safety limitations. + +**Requirements:** R1-R18; supports all flows and success criteria. + +**Dependencies:** U1-U5. + +**Files:** + +- Modify `README.md` +- Modify `apps/docs/src/content/docs/configuration.mdx` +- Modify `apps/docs/src/content/docs/install.mdx` +- Modify `apps/docs/src/content/docs/troubleshooting.mdx` +- Modify `apps/docs/src/content/docs/reference/config.mdx` +- Modify `docs/architecture.md` +- Modify `schemas/caplets-config.schema.json` +- Modify `apps/landing/public/config.schema.json` +- Create `.changeset/global-serve-config.md` +- Modify or add public-doc safety tests under `packages/core/test/cli.test.ts` or the existing docs-check path + +**Approach:** Present `serve` as a user/global config block for HTTP serve defaults, not a project setting and not a transport preference. Explain precedence, `publicOrigins`, daemon restart behavior, and the local setup safety rule. Regenerate schema and docs artifacts from the schema source rather than hand-editing generated reference content. + +**Patterns to follow:** Existing generated config reference flow, public-doc safety checks for daemon-first setup, and changeset naming conventions. + +**Test scenarios:** + +- Happy path: generated config reference includes `serve` fields and examples in the global config context. +- Error path: docs safety checks reject guidance that puts `serve` in project config or uses `serve.allowedHosts`/`serve.transport` as config. +- Integration: README and docs explain daemon-first setup remains local loopback by default despite global serve defaults. +- Integration: schema checks prove generated schema assets are current. + +**Verification:** Documentation and generated schema checks pass; public docs consistently describe global-only serve defaults, public origins, precedence, daemon restart behavior, and local setup safety. + +--- + +## Verification Contract + +- **Config and schema:** Focused config tests must prove global `serve` parsing, invalid-value rejection, project `serve` warning/ignore behavior, and generated schema freshness. +- **Serve resolution:** Focused serve-option and CLI tests must prove CLI > environment > global config > built-in defaults, stdio isolation, public-origin normalization, and boolean false override behavior. +- **HTTP safety:** Focused HTTP serve tests must prove canonical public-origin identity, additional-origin host acceptance, trusted-proxy guardrails, and no unconfigured Host header can mutate remote credential state. +- **Daemon lifecycle:** Focused daemon tests must prove explicit daemon settings override global config, daemon restart reads current global defaults, legacy daemon configs behave conservatively, and status output remains redacted. +- **Setup safety:** Focused setup tests must prove local daemon onboarding remains loopback and credential-free, and unsafe daemons stop setup before MCP/native integration mutation. +- **Repository gate:** Run the repo's standard verification gate after focused checks and generated artifacts are current. + +## Definition of Done + +- Global user config supports optional `serve` defaults for every existing HTTP serve setting covered by the Product Contract, plus `publicOrigins`. +- Project config `serve` is warned and ignored without losing valid project Caplets. +- Foreground HTTP serve, daemon-managed serve, and relevant HTTP compatibility paths share one source-precedence model. +- Daemon restarts pick up current global defaults for non-explicit daemon fields without weakening daemon-first local setup. +- Public-origin list behavior preserves remote credential and Host-header safety. +- Generated schema, docs reference, user docs, and changeset are updated. +- Focused tests, generated checks, docs checks, typecheck, and the full repository verification gate pass. + +## Deferred to Implementation + +- Exact internal type names and helper boundaries for explicit daemon serve metadata. +- Whether existing boolean CLI parsing can represent explicit false directly or needs added negated command options. +- The minimal migration shape for legacy daemon configs that preserves safety while avoiding unnecessary reinstall requirements. diff --git a/packages/core/package.json b/packages/core/package.json index 2a1b8578..3aa2b257 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -104,6 +104,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@sentry/node": "^10.62.0", "@ungap/structured-clone": "^1.3.2", + "add-mcp": "1.13.0", "ajv": "^8.20.0", "commander": "^15.0.0", "formdata-node": "^6.0.3", diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index f25ada41..6ac55d9f 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -68,7 +68,9 @@ import { runSetup, type SetupCommandRunner, type SetupFormat, + type SetupMcpOperations, type SetupOptions, + type SetupPhaseOperations, type SetupPromptReader, } from "./cli/setup"; import { @@ -78,6 +80,7 @@ import { defaultUpdateCheckCacheDir, defaultUpdateCheckStateDir, defaultCapletsLockfilePath, + loadGlobalServeDefaults, loadConfigWithSources, loadLocalOverlayConfigWithSources, resolveCapletsRoot, @@ -186,6 +189,8 @@ type CliIO = { attachServe?: (options: AttachServeOptions) => Promise; daemon?: DaemonOperationOptions; runSetupCommand?: SetupCommandRunner; + setupOperations?: SetupPhaseOperations; + mcpOperations?: SetupMcpOperations; readStdin?: () => Promise; }; @@ -1409,6 +1414,7 @@ export function createProgram(io: CliIO = {}): Command { const writeErr = io.writeErr ?? ((value: string) => process.stderr.write(value)); const env = io.env ?? process.env; const currentConfigPath = () => envConfigPath(env); + const currentServeDefaults = () => loadGlobalServeDefaults(env); const telemetryContext = (): TelemetryCliContext => ({ env, configPath: currentConfigPath(), @@ -1727,7 +1733,8 @@ export function createProgram(io: CliIO = {}): Command { trustProxy?: boolean; }) => { printTelemetryNotice("serve"); - const resolved = resolveServeOptions(options); + const defaults = options.transport === "http" ? currentServeDefaults() : undefined; + const resolved = resolveServeOptions(options, env, defaults); const configPath = currentConfigPath(); const runner = io.serve ?? @@ -2583,6 +2590,7 @@ export function createProgram(io: CliIO = {}): Command { .option("--remote-url ", "remote Caplets service base URL") .option("--server-url ", "remote Caplets service base URL") .option("--output ", "config path to write for generic MCP setup") + .option("--client ", "MCP client id to configure through add-mcp") .option("--dry-run", "print actions without running commands or writing files") .option("--yes", "approve Caplet setup commands for the exact current content hash") .option("--target ", "Caplet setup target: local, remote, or cloud", parseSetupTarget) @@ -2595,6 +2603,7 @@ export function createProgram(io: CliIO = {}): Command { remoteUrl?: string; serverUrl?: string; output?: string; + client?: string; dryRun?: boolean; yes?: boolean; target?: "local" | "remote" | "cloud"; @@ -2604,6 +2613,8 @@ export function createProgram(io: CliIO = {}): Command { printTelemetryNotice("cli"); const setupOptions: SetupOptions = { ...options, env }; if (io.runSetupCommand) setupOptions.runCommand = io.runSetupCommand; + if (io.setupOperations) setupOptions.setupOperations = io.setupOperations; + if (io.mcpOperations) setupOptions.mcpOperations = io.mcpOperations; if (!integration) { const promptHandle = createSetupPromptHandle(io, writeOut); if (!promptHandle) { diff --git a/packages/core/src/cli/add-mcp-adapter.ts b/packages/core/src/cli/add-mcp-adapter.ts new file mode 100644 index 00000000..918368df --- /dev/null +++ b/packages/core/src/cli/add-mcp-adapter.ts @@ -0,0 +1,110 @@ +import { + agents, + detectGlobalAgents as detectGlobalAddMcpAgents, + detectProjectAgents as detectProjectAddMcpAgents, + getAgentTypes, + upsertServer, + type AgentType, + type InstallResult, + type OptionalField, +} from "add-mcp"; +import { CapletsError } from "../errors"; + +export type AddMcpClientId = AgentType; + +export type AddMcpClient = { + id: AddMcpClientId; + displayName: string; + configPath: string; + projectConfigPath?: string; + supportsStdio: boolean; +}; + +export type AddMcpDetectionOptions = { + cwd?: string; + global?: boolean; +}; + +export type UpsertCapletsMcpServerOptions = { + clientId: string; + daemonBaseUrl: string; + cwd?: string; + local?: boolean; +}; + +export type UpsertCapletsMcpServerResult = { + clientId: AddMcpClientId; + success: boolean; + path: string; + error?: string; + droppedFields?: OptionalField[]; + extraPaths?: string[]; +}; + +export function listSupportedAddMcpClients(): AddMcpClient[] { + return getAgentTypes().map(addMcpClientForId); +} + +export async function detectAddMcpClients( + options: AddMcpDetectionOptions = {}, +): Promise { + const detected = options.global + ? await detectGlobalAddMcpAgents() + : detectProjectAddMcpAgents(options.cwd); + const detectedIds = new Set(detected); + return listSupportedAddMcpClients().filter((client) => detectedIds.has(client.id)); +} + +export async function upsertCapletsMcpServer( + options: UpsertCapletsMcpServerOptions, +): Promise { + const clientId = parseAddMcpClientId(options.clientId); + const result = upsertServer( + clientId, + "caplets", + { + command: "caplets", + args: ["attach", options.daemonBaseUrl], + }, + { + local: options.local ?? true, + ...(options.cwd ? { cwd: options.cwd } : {}), + }, + ); + return addMcpResult(clientId, result); +} + +export function parseAddMcpClientId(value: string): AddMcpClientId { + if ((getAgentTypes() as string[]).includes(value)) { + return value as AddMcpClientId; + } + throw new CapletsError( + "REQUEST_INVALID", + `MCP client must be one of: ${getAgentTypes().join(", ")}`, + ); +} + +function addMcpClientForId(id: AddMcpClientId): AddMcpClient { + const agent = agents[id]; + return { + id, + displayName: agent.displayName, + configPath: agent.configPath, + ...(agent.localConfigPath ? { projectConfigPath: agent.localConfigPath } : {}), + supportsStdio: agent.supportedTransports.includes("stdio"), + }; +} + +function addMcpResult( + clientId: AddMcpClientId, + result: InstallResult, +): UpsertCapletsMcpServerResult { + return { + clientId, + success: result.success, + path: result.path, + ...(result.error ? { error: result.error } : {}), + ...(result.droppedFields?.length ? { droppedFields: result.droppedFields } : {}), + ...(result.extraPaths?.length ? { extraPaths: result.extraPaths } : {}), + }; +} diff --git a/packages/core/src/cli/completion.ts b/packages/core/src/cli/completion.ts index 115569a0..9ad4e979 100644 --- a/packages/core/src/cli/completion.ts +++ b/packages/core/src/cli/completion.ts @@ -5,6 +5,7 @@ import { type CompletionConfig, } from "../config"; import { CapletsError } from "../errors"; +import { listSupportedAddMcpClients } from "./add-mcp-adapter"; import { listCaplets } from "./inspection"; import { capletIdCommands, @@ -37,11 +38,17 @@ export type CompletionOptions = { const optionValueSuggestions: Record> = { "*": { "--format": ["markdown", "md", "plain", "json"] }, serve: { "--transport": ["stdio", "http"] }, - setup: { "--format": ["plain", "json"] }, + setup: { "--format": ["plain", "json"], "--client": setupMcpClientIds() }, "add:mcp": { "--transport": ["http", "sse"] }, "add:cli": { "--include": ["git", "gh", "package"] }, }; +function setupMcpClientIds(): string[] { + return listSupportedAddMcpClients() + .filter((client) => client.supportsStdio) + .map((client) => client.id); +} + export function completionScript(shell: CompletionShell): string { switch (shell) { case "bash": diff --git a/packages/core/src/cli/setup.ts b/packages/core/src/cli/setup.ts index 7bdaa572..36638615 100644 --- a/packages/core/src/cli/setup.ts +++ b/packages/core/src/cli/setup.ts @@ -1,9 +1,21 @@ import { execFile } from "node:child_process"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { promisify } from "node:util"; +import { loadConfig, resolveConfigPath, resolveProjectConfigPath } from "../config"; +import { daemonClientBaseUrl, daemonStatus, installDaemon } from "../daemon"; +import type { DaemonConfig, DaemonOperationOptions } from "../daemon/types"; import { CapletsError } from "../errors"; import { isCapletsCloudUrl } from "../remote/options"; +import { isLoopbackHost } from "../server/options"; +import { + detectAddMcpClients, + listSupportedAddMcpClients, + upsertCapletsMcpServer, + type AddMcpClient, +} from "./add-mcp-adapter"; +import { nativeDefaultsPath, writeNativeDefaults } from "../native/user-settings"; +import { initConfig } from "./init"; import { runCapletSetupCli } from "./setup-caplet"; import { isSetupTargetKind, type SetupTargetKind } from "../setup/types"; @@ -29,15 +41,64 @@ export type SetupCommandResult = { export type SetupCommandRunner = (command: string, args: string[]) => Promise; export type SetupPromptReader = (prompt: string) => Promise; +export type SetupPhaseStatus = "planned" | "completed" | "reused"; + +export type SetupPhaseResult = { + phase: "config" | "daemon" | "integration"; + label: string; + status: SetupPhaseStatus; + path?: string; + daemonBaseUrl?: string; + message?: string; +}; + +export type SetupPhaseContext = { + env: NodeJS.ProcessEnv | Record; +}; + +export type SetupPhaseOperations = { + ensureUserConfig?: (context: SetupPhaseContext) => Promise | SetupPhaseResult; + ensureDaemon?: (context: SetupPhaseContext) => Promise | SetupPhaseResult; +}; + +export type SetupMcpClient = Omit & { id: string }; + +export type SetupMcpUpsertOptions = { + clientId: string; + daemonBaseUrl: string; + local: boolean; +}; + +export type SetupMcpUpsertResult = { + clientId: string; + success: boolean; + path: string; + error?: string; + droppedFields?: string[]; + extraPaths?: string[]; +}; + +export type SetupMcpOperations = { + listSupportedClients?: () => SetupMcpClient[]; + detectClients?: () => Promise | SetupMcpClient[]; + upsertServer?: ( + options: SetupMcpUpsertOptions, + ) => Promise | SetupMcpUpsertResult; +}; + export type SetupOptions = { remote?: boolean; remoteUrl?: string; serverUrl?: string; output?: string; + client?: string; dryRun?: boolean; env?: NodeJS.ProcessEnv | Record; format?: SetupFormat; runCommand?: SetupCommandRunner; + setupOperations?: SetupPhaseOperations; + mcpOperations?: SetupMcpOperations; + nativeDefaultsPath?: string; yes?: boolean; target?: SetupTargetOption; }; @@ -48,13 +109,28 @@ export type InteractiveSetupOptions = SetupOptions & { type SetupAction = | { type: "command"; label: string; command: string; args: string[] } - | { type: "writeFile"; label: string; path: string; content: string }; + | { type: "writeFile"; label: string; path: string; content: string } + | { + type: "mcpClient"; + label: string; + clientId: string; + clientName: string; + daemonBaseUrl: string; + path: string; + scope: "project" | "global"; + } + | { type: "nativeDefaults"; label: string; daemonBaseUrl: string; path?: string }; type SetupActionResult = { label: string; command?: string; path?: string; status: "planned" | "completed"; + clientId?: string; + clientName?: string; + scope?: "project" | "global"; + droppedFields?: string[]; + extraPaths?: string[]; }; type SetupResult = { @@ -63,36 +139,56 @@ type SetupResult = { mode: "local" | "remote"; targetKind: SetupTargetKind; dryRun: boolean; + phases: SetupPhaseResult[]; actions: SetupActionResult[]; nextSteps: string[]; }; -const localMcpConfig = `{ - "mcpServers": { - "caplets": { - "command": "caplets", - "args": ["serve"] - } - } -} +function localMcpConfig(daemonBaseUrl: string): string { + return `${JSON.stringify( + { + mcpServers: { + caplets: { + command: "caplets", + args: ["attach", daemonBaseUrl], + }, + }, + }, + null, + 2, + )} `; +} export function formatSetupMenu(): string { return [ "Usage: caplets setup [integration]", "", + "daemon-first local setup initializes Caplets config, starts or reuses the local daemon,", + "then configures the selected integration to run caplets attach as a thin client.", + "", "Supported integrations:", " codex Add Caplets to Codex MCP config", " claude-code Add Caplets to Claude Code MCP config", - " opencode Run OpenCode native plugin install", - " pi Run Pi extension install", - " mcp-client Write a generic MCP client config with --output", + " opencode Install the native OpenCode plugin and daemon defaults", + " pi Install the native Pi extension and daemon defaults", + " mcp-client Pick detected MCP clients or pass --client for any supported add-mcp client", + "", + "MCP client selection:", + " Interactive setup shows detected MCP clients first; choose all to list all supported MCP clients.", + "", + "Remote setup:", + " Use --remote-url (or --server-url ) to configure remote/cloud attach instead of the local daemon.", + "", + "Advanced manual config fallback:", + " caplets setup mcp-client --output ./caplets.mcp.json", "", "Examples:", " caplets setup", " caplets setup codex", " caplets setup opencode --dry-run", - " caplets setup mcp-client --output ./caplets.mcp.json", + " caplets setup mcp-client --client codex", + " caplets setup codex --remote-url https://caplets.example.com/caplets", "", ].join("\n"); } @@ -123,14 +219,8 @@ export async function runInteractiveSetup(options: InteractiveSetupOptions): Pro for (const integration of selected) { const setupOptions: SetupOptions = { ...options }; - if (integration === "mcp-client" && !setupOptions.output) { - const output = nonEmpty( - await options.readPrompt("Path to write generic MCP config (--output): "), - ); - if (!output) { - throw new CapletsError("REQUEST_INVALID", "mcp-client setup requires an output path"); - } - setupOptions.output = output; + if (integration === "mcp-client" && !setupOptions.output && !setupOptions.client) { + setupOptions.client = await promptForMcpClient(setupOptions, options.readPrompt); } chunks.push(await runSetup(integration, setupOptions)); } @@ -159,11 +249,85 @@ export async function runSetup(integration: string, options: SetupOptions = {}): async function executeSetup(integration: string, options: SetupOptions): Promise { const id = parseSetupIntegrationId(integration); - const definition = setupDefinition(id, options); - const actions: SetupActionResult[] = []; + setupDefinition(id, options, "http://127.0.0.1:5387/"); const runner = options.runCommand ?? defaultSetupCommandRunner; + const phases: SetupPhaseResult[] = []; + let daemonBaseUrl: string | undefined; + + if (!isRemoteSetup(options)) { + if (options.dryRun) { + const planned = plannedLocalSetupPhases(options); + phases.push(planned.config, planned.daemon); + daemonBaseUrl = planned.daemon.daemonBaseUrl; + } else { + phases.push(await ensureUserConfigPhase(options)); + const daemonPhase = await ensureDaemonPhase(options); + daemonBaseUrl = daemonPhase.daemonBaseUrl; + phases.push(daemonPhase); + } + } + + const definition = setupDefinition(id, options, daemonBaseUrl); + const actions: SetupActionResult[] = []; for (const action of definition.actions) { + if (action.type === "mcpClient") { + const commandText = formatCommand("caplets", ["attach", action.daemonBaseUrl]); + if (options.dryRun) { + actions.push({ + label: action.label, + command: commandText, + path: action.path, + status: "planned", + clientId: action.clientId, + clientName: action.clientName, + scope: action.scope, + }); + continue; + } + const result = await mcpOperations(options).upsertServer({ + clientId: action.clientId, + daemonBaseUrl: action.daemonBaseUrl, + local: true, + }); + if (!result.success) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `Failed to configure ${action.clientName} MCP config${ + result.error ? `: ${result.error}` : "" + }. The Caplets daemon is still ready; rerun caplets setup mcp-client --client ${action.clientId} to retry.`, + ); + } + actions.push({ + label: action.label, + command: commandText, + path: result.path, + status: "completed", + clientId: action.clientId, + clientName: action.clientName, + scope: action.scope, + ...(result.droppedFields?.length ? { droppedFields: result.droppedFields } : {}), + ...(result.extraPaths?.length ? { extraPaths: result.extraPaths } : {}), + }); + continue; + } + + if (action.type === "nativeDefaults") { + const path = action.path ?? nativeDefaultsPathForSetup(options); + if (!options.dryRun) { + writeNativeDefaults( + { source: "setup", daemon: { url: action.daemonBaseUrl } }, + { path, env: setupEnv(options) }, + ); + } + actions.push({ + label: action.label, + path, + status: options.dryRun ? "planned" : "completed", + }); + continue; + } + if (action.type === "command") { const commandText = formatCommand(action.command, action.args); if (!options.dryRun) { @@ -202,57 +366,201 @@ async function executeSetup(integration: string, options: SetupOptions): Promise }); } + phases.push({ + phase: "integration", + label: `Configure ${definition.name}`, + status: options.dryRun ? "planned" : "completed", + message: `${actions.length} setup action${actions.length === 1 ? "" : "s"}`, + }); + return { integration: id, name: definition.name, mode: isRemoteSetup(options) ? "remote" : "local", targetKind: resolveSetupTargetKind(options), dryRun: Boolean(options.dryRun), + phases, actions, nextSteps: definition.nextSteps, }; } +function plannedLocalSetupPhases(options: SetupOptions): { + config: SetupPhaseResult; + daemon: SetupPhaseResult; +} { + return { + config: { + phase: "config", + label: "Initialize user Caplets config", + status: "planned", + path: userConfigPath(setupEnv(options)), + }, + daemon: { + phase: "daemon", + label: "Start local Caplets daemon", + status: "planned", + daemonBaseUrl: "http://127.0.0.1:5387/", + message: "install/start/reuse default daemon and verify health", + }, + }; +} + +async function ensureUserConfigPhase(options: SetupOptions): Promise { + const operation = options.setupOperations?.ensureUserConfig ?? defaultEnsureUserConfig; + return await operation({ env: setupEnv(options) }); +} + +async function ensureDaemonPhase(options: SetupOptions): Promise { + const operation = options.setupOperations?.ensureDaemon ?? defaultEnsureDaemon; + try { + const phase = await operation({ env: setupEnv(options) }); + if (!phase.daemonBaseUrl) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + "Caplets daemon setup did not return a daemon URL.", + ); + } + return phase; + } catch (error) { + if (error instanceof CapletsError) throw error; + throw new CapletsError( + "SERVER_UNAVAILABLE", + `Caplets daemon setup failed before integration config mutation${ + error instanceof Error ? `: ${error.message}` : "" + }`, + ); + } +} + +function defaultEnsureUserConfig(context: SetupPhaseContext): SetupPhaseResult { + const path = userConfigPath(context.env); + if (!existsSync(path)) { + initConfig({ path }); + loadConfig(path, projectConfigPath(context.env)); + return { + phase: "config", + label: "Initialize user Caplets config", + status: "completed", + path, + message: "created user config", + }; + } + + loadConfig(path, projectConfigPath(context.env)); + return { + phase: "config", + label: "Initialize user Caplets config", + status: "reused", + path, + message: "existing user config is valid", + }; +} + +async function defaultEnsureDaemon(context: SetupPhaseContext): Promise { + const operation = daemonOperationOptions(context.env); + const status = await daemonStatus(operation); + if (status.config) assertCredentialFreeLocalSetupDaemonHost(status.config); + if (status.installed && status.running && status.health?.ok && status.config) { + if (!isCredentialFreeLocalSetupDaemon(status.config)) { + return await installCredentialFreeLocalSetupDaemon(operation); + } + return { + phase: "daemon", + label: "Reuse local Caplets daemon", + status: "reused", + daemonBaseUrl: daemonClientBaseUrl(status.config).toString(), + message: "existing daemon is healthy", + }; + } + + return await installCredentialFreeLocalSetupDaemon(operation); +} + +async function installCredentialFreeLocalSetupDaemon( + operation: DaemonOperationOptions, +): Promise { + const result = await installDaemon( + { start: true, host: "127.0.0.1", allowUnauthenticatedHttp: true }, + operation, + ); + const config = result.status.config ?? result.config; + assertCredentialFreeLocalSetupDaemonHost(config); + const health = result.status.health ?? result.validation; + if (!result.status.running || health?.ok !== true) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `Caplets daemon health verification failed${health?.error ? `: ${health.error}` : ""}`, + ); + } + + return { + phase: "daemon", + label: "Start local Caplets daemon", + status: "completed", + daemonBaseUrl: daemonClientBaseUrl(config).toString(), + message: result.plannedActions.join(", "), + }; +} + +function isCredentialFreeLocalSetupDaemon(config: Pick): boolean { + return ( + config.serve.allowUnauthenticatedHttp === true && + config.serve.auth.type === "development_unauthenticated" + ); +} + +function assertCredentialFreeLocalSetupDaemonHost(config: Pick): void { + if (!isLoopbackHost(config.serve.host)) { + throw new CapletsError( + "REQUEST_INVALID", + `caplets setup cannot configure credential-free local attach for daemon host ${config.serve.host}. Reinstall the local daemon on 127.0.0.1 or use remote setup.`, + ); + } +} + +function setupEnv(options: SetupOptions): NodeJS.ProcessEnv | Record { + return options.env ?? process.env; +} + +function daemonOperationOptions( + env: NodeJS.ProcessEnv | Record, +): DaemonOperationOptions { + return { + env, + healthTimeoutMs: 10_000, + healthIntervalMs: 200, + }; +} + +function userConfigPath(env: NodeJS.ProcessEnv | Record): string { + return resolveConfigPath(nonEmpty(env.CAPLETS_CONFIG)); +} + +function projectConfigPath(env: NodeJS.ProcessEnv | Record): string { + return nonEmpty(env.CAPLETS_PROJECT_CONFIG) ?? resolveProjectConfigPath(); +} + +function nativeDefaultsPathForSetup(options: SetupOptions): string { + return nativeDefaultsPath({ + ...(options.nativeDefaultsPath ? { path: options.nativeDefaultsPath } : {}), + env: setupEnv(options), + }); +} + function setupDefinition( id: SetupIntegrationId, options: SetupOptions, + daemonBaseUrl: string | undefined, ): { name: string; actions: SetupAction[]; nextSteps: string[] } { if (isRemoteSetup(options)) return remoteSetupDefinition(id, options); + const localDaemonBaseUrl = daemonBaseUrl ?? "http://127.0.0.1:5387/"; switch (id) { case "codex": - return { - name: "Codex", - actions: [ - { - type: "command", - label: "Add Caplets MCP server to Codex", - command: "codex", - args: codexMcpAddArgs(["serve"]), - }, - ], - nextSteps: [ - "In Codex, run /mcp to confirm the caplets server is connected.", - "Try a premade Caplet: caplets install spiritledsoftware/caplets github", - 'Ask Codex: codex "try using the github caplet"', - ], - }; + return mcpClientSetupDefinition("codex", "Codex", localDaemonBaseUrl, options); case "claude-code": - return { - name: "Claude Code", - actions: [ - { - type: "command", - label: "Add Caplets MCP server to Claude Code", - command: "claude", - args: claudeMcpAddArgs(["serve"]), - }, - ], - nextSteps: [ - "In Claude Code, run /mcp to confirm the caplets server is connected.", - "Try a premade Caplet: caplets install spiritledsoftware/caplets github", - ], - }; + return mcpClientSetupDefinition("claude-code", "Claude Code", localDaemonBaseUrl, options); case "opencode": return { name: "OpenCode", @@ -263,6 +571,12 @@ function setupDefinition( command: "opencode", args: ["plugin", "@caplets/opencode", "--global"], }, + { + type: "nativeDefaults", + label: "Write Caplets native daemon defaults", + daemonBaseUrl: localDaemonBaseUrl, + ...(options.nativeDefaultsPath ? { path: options.nativeDefaultsPath } : {}), + }, ], nextSteps: [ "OpenCode reads local Caplets config and exposes native caplets_ tools.", @@ -279,6 +593,12 @@ function setupDefinition( command: "pi", args: ["install", "npm:@caplets/pi"], }, + { + type: "nativeDefaults", + label: "Write Caplets native daemon defaults", + daemonBaseUrl: localDaemonBaseUrl, + ...(options.nativeDefaultsPath ? { path: options.nativeDefaultsPath } : {}), + }, ], nextSteps: [ "Pi reads local Caplets config and exposes native tools.", @@ -286,10 +606,13 @@ function setupDefinition( ], }; case "mcp-client": + if (options.client) { + return mcpClientSetupDefinition(options.client, "MCP client", localDaemonBaseUrl, options); + } if (!options.output) { throw new CapletsError( "REQUEST_INVALID", - "caplets setup mcp-client requires --output because MCP clients do not share one config path", + "caplets setup mcp-client requires --client or --output ", ); } return { @@ -299,7 +622,7 @@ function setupDefinition( type: "writeFile", label: "Write generic MCP stdio config", path: options.output, - content: localMcpConfig, + content: localMcpConfig(localDaemonBaseUrl), }, ], nextSteps: ["Import the written MCP config into your MCP client."], @@ -307,6 +630,114 @@ function setupDefinition( } } +function mcpClientSetupDefinition( + clientId: string, + fallbackName: string, + daemonBaseUrl: string, + options: SetupOptions, +): { name: string; actions: SetupAction[]; nextSteps: string[] } { + const client = resolveSetupMcpClient(clientId, options); + const scope = client.projectConfigPath ? "project" : "global"; + const path = client.projectConfigPath ?? client.configPath; + const name = fallbackName === "MCP client" ? client.displayName : fallbackName; + return { + name, + actions: [ + { + type: "mcpClient", + label: `Add Caplets MCP server to ${client.displayName}`, + clientId: client.id, + clientName: client.displayName, + daemonBaseUrl, + path, + scope, + }, + ], + nextSteps: [ + `Caplets daemon is ready at ${daemonBaseUrl}; ${client.displayName} runs caplets attach as a thin client.`, + `Restart or reload ${client.displayName} and confirm the caplets MCP server is connected.`, + "Try a premade Caplet: caplets install spiritledsoftware/caplets github", + ], + }; +} + +function resolveSetupMcpClient(clientId: string, options: SetupOptions): SetupMcpClient { + const clients = mcpOperations(options).listSupportedClients(); + const client = clients.find((entry) => entry.id === clientId); + if (!client) { + throw new CapletsError( + "REQUEST_INVALID", + `MCP client must be one of: ${clients.map((entry) => entry.id).join(", ")}`, + ); + } + if (!client.supportsStdio) { + throw new CapletsError( + "REQUEST_INVALID", + `${client.displayName} does not support stdio MCP servers through add-mcp.`, + ); + } + return client; +} + +async function promptForMcpClient( + options: SetupOptions, + readPrompt: SetupPromptReader, +): Promise { + const operations = mcpOperations(options); + const detected = (await operations.detectClients()).filter((client) => client.supportsStdio); + const supported = operations.listSupportedClients().filter((client) => client.supportsStdio); + const primary = detected.length > 0 ? detected : supported; + const answer = nonEmpty(await readPrompt(formatMcpClientPrompt(primary, detected.length > 0))); + if (answer && isShowAllMcpClientsAnswer(answer)) { + return parseMcpClientPromptAnswer( + nonEmpty(await readPrompt(formatMcpClientPrompt(supported, false))) ?? "", + supported, + ); + } + return parseMcpClientPromptAnswer(answer ?? primary[0]?.id ?? "", primary); +} + +function formatMcpClientPrompt(clients: SetupMcpClient[], detected: boolean): string { + const lines = [ + detected ? "Detected MCP clients:" : "Supported MCP clients:", + ...clients.map( + (client, index) => + ` ${index + 1}. ${client.displayName} (${client.id}) -> ${ + client.projectConfigPath ?? client.configPath + }`, + ), + ]; + if (detected) lines.push(" all. Show all supported MCP clients"); + lines.push("", "Enter MCP client id or number: "); + return lines.join("\n"); +} + +function parseMcpClientPromptAnswer(answer: string, clients: SetupMcpClient[]): string { + const normalized = answer.trim(); + const byIndex = Number(normalized); + if (Number.isInteger(byIndex) && byIndex >= 1 && byIndex <= clients.length) { + return clients[byIndex - 1]!.id; + } + const client = clients.find( + (entry) => + entry.id === normalized || entry.displayName.toLowerCase() === normalized.toLowerCase(), + ); + if (client) return client.id; + throw new CapletsError("REQUEST_INVALID", `unknown MCP client selection: ${answer || ""}`); +} + +function isShowAllMcpClientsAnswer(answer: string): boolean { + return ["all", "a", "show all", "show-all"].includes(answer.trim().toLowerCase()); +} + +function mcpOperations(options: SetupOptions): Required { + return { + listSupportedClients: options.mcpOperations?.listSupportedClients ?? listSupportedAddMcpClients, + detectClients: options.mcpOperations?.detectClients ?? detectAddMcpClients, + upsertServer: options.mcpOperations?.upsertServer ?? upsertCapletsMcpServer, + }; +} + function remoteSetupDefinition( id: SetupIntegrationId, options: SetupOptions, @@ -500,7 +931,25 @@ function formatSetupResult(result: SetupResult): string { `${result.dryRun ? "Dry run" : "Completed"} ${result.name} setup (${result.mode}, ${result.targetKind})`, "", ]; + for (const phase of result.phases) { + const details = phase.daemonBaseUrl ?? phase.path ?? phase.message; + lines.push(`- ${phase.status} ${phase.phase}: ${phase.label}${details ? ` (${details})` : ""}`); + } for (const action of result.actions) { + if (action.clientId) { + const clientName = action.clientName ?? action.label; + const scope = action.scope ? ` (${action.scope})` : ""; + const path = action.path ? ` at ${action.path}` : ""; + lines.push(`- ${action.status}: configured ${clientName} MCP client${scope}${path}`); + if (action.command) lines.push(` command: ${action.command}`); + if (action.droppedFields?.length) { + lines.push(` add-mcp dropped unsupported fields: ${action.droppedFields.join(", ")}`); + } + if (action.extraPaths?.length) { + lines.push(` add-mcp additional paths: ${action.extraPaths.join(", ")}`); + } + continue; + } if (action.command) lines.push(`- ${action.status}: ${action.command}`); if (action.path) lines.push(`- ${action.status}: wrote ${action.path}`); } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 1d05ddca..6e62689f 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -2,7 +2,12 @@ import { existsSync, readFileSync } from "node:fs"; import { basename, dirname, isAbsolute, join } from "node:path"; import { z } from "zod"; import { loadCapletFilesWithPaths, loadCapletFilesWithPathsBestEffort } from "./caplet-files"; -import { resolveCapletsRoot, resolveConfigPath, resolveProjectConfigPath } from "./config/paths"; +import { + defaultConfigPath, + resolveCapletsRoot, + resolveConfigPath, + resolveProjectConfigPath, +} from "./config/paths"; import { FORBIDDEN_HEADERS, HEADER_NAME_PATTERN, @@ -339,6 +344,17 @@ export type CapletsOptions = { completion: CompletionConfig; }; +export type ServeConfig = { + host?: string | undefined; + port?: number | undefined; + path?: string | undefined; + remoteStatePath?: string | undefined; + upstreamUrl?: string | undefined; + allowUnauthenticatedHttp?: boolean | undefined; + trustProxy?: boolean | undefined; + publicOrigins: string[]; +}; + export type CompletionConfig = { discoveryTimeoutMs: number; overallTimeoutMs: number; @@ -349,6 +365,7 @@ export type CompletionConfig = { export type CapletsConfig = { version: 1; telemetry?: boolean | undefined; + serve?: ServeConfig | undefined; options: CapletsOptions; namespaceAliases: NamespaceAliasesConfig; mcpServers: Record; @@ -1151,6 +1168,7 @@ type ConfigSchemaCliToolsValue = z.infer; type ConfigSchemaCapletSetValue = z.infer; type ConfigInput = { telemetry?: unknown; + serve?: unknown; namespaceAliases?: unknown; mcpServers?: Record; openapiEndpoints?: Record; @@ -1173,6 +1191,59 @@ const CAPLET_BACKEND_KEYS = [ ] as const satisfies ReadonlyArray; const CAPLET_BACKEND_KEY_SET = new Set(CAPLET_BACKEND_KEYS); +const SERVE_PUBLIC_ORIGIN_PATTERN = /^https?:\/\/(?![^/?#]*@)[^/?#]+\/?$/u; + +const publicOriginSchema = z + .string() + .describe("Public HTTP(S) origin for DNS rebinding and credential audience checks.") + .regex(SERVE_PUBLIC_ORIGIN_PATTERN, { + message: + "public origin must be an http(s) origin without credentials, path, query, or fragment", + }) + .refine(isAllowedServePublicOrigin, { + message: + "public origin must be an http(s) origin without credentials, path, query, or fragment; http is only allowed for loopback development origins", + }) + .transform(normalizePublicOrigin); + +const serveConfigSchema = z + .object({ + host: z.string().trim().min(1).optional().describe("Default HTTP bind host for caplets serve."), + port: z.number().int().min(1).max(65_535).optional().describe("Default HTTP port."), + path: z + .string() + .refine((value) => value.startsWith("/") && !value.includes("?") && !value.includes("#"), { + message: "serve path must start with / and must not include query or fragment", + }) + .optional() + .describe("Default HTTP base path."), + remoteStatePath: z + .string() + .trim() + .min(1) + .optional() + .describe("Default remote credential state directory for HTTP serve."), + upstreamUrl: z + .string() + .refine(isAllowedHttpBaseUrl) + .optional() + .describe("Default upstream Caplets URL for stacked HTTP serve."), + allowUnauthenticatedHttp: z + .boolean() + .optional() + .describe("Opt in to unauthenticated HTTP serving; intended only for trusted local use."), + trustProxy: z + .boolean() + .optional() + .describe("Trust proxy headers when deriving public HTTP request URLs."), + publicOrigins: z + .array(publicOriginSchema) + .default([]) + .describe("Additional public HTTP origins."), + }) + .strict(); + +const serveDefaultsFileSchema = z.object({ serve: serveConfigSchema.optional() }).passthrough(); type MissingEnvReference = { name: string; @@ -1209,6 +1280,9 @@ function configSchemaFor( .boolean() .optional() .describe("Set false to disable anonymous Caplets telemetry for this user config."), + serve: serveConfigSchema + .optional() + .describe("User-owned HTTP serve defaults. Ignored from project config for security."), completion: z .object({ discoveryTimeoutMs: z.number().int().positive().default(750), @@ -1678,7 +1752,10 @@ export function loadConfigWithSources( const userConfig = hasUserConfig ? readPublicConfigInput(path) : undefined; const userCaplets = loadCapletFilesWithPaths(resolveCapletsRoot(path)); const projectConfig = hasProjectConfig - ? rejectProjectConfigExecutableBackendMaps(readPublicConfigInput(projectPath), projectPath) + ? rejectProjectConfigExecutableBackendMaps( + stripProjectServeConfig(readPublicConfigInput(projectPath), projectPath), + projectPath, + ) : undefined; const projectCapletsRoot = resolveProjectCapletsRootForConfigPath(projectPath); const projectCaplets = projectCapletsRoot @@ -1725,12 +1802,26 @@ export function loadGlobalConfig( ).config; } +export function loadGlobalServeDefaults( + env: NodeJS.ProcessEnv | Record = process.env, + options: { home?: string | undefined; platform?: NodeJS.Platform | undefined } = {}, +): ServeConfig | undefined { + const explicitPath = env.CAPLETS_CONFIG?.trim(); + const configPath = + explicitPath || defaultConfigPath(env as NodeJS.ProcessEnv, options.home, options.platform); + if (!existsSync(configPath)) return undefined; + return readServeDefaultsInput(configPath); +} + export function loadProjectConfig( projectPath = resolveProjectConfigPath(), options: Pick = {}, ): CapletsConfig { const projectConfig = existsSync(projectPath) - ? rejectProjectConfigExecutableBackendMaps(readPublicConfigInput(projectPath), projectPath) + ? rejectProjectConfigExecutableBackendMaps( + stripProjectServeConfig(readPublicConfigInput(projectPath), projectPath), + projectPath, + ) : undefined; const projectCapletsRoot = resolveProjectCapletsRootForConfigPath(projectPath); const projectCaplets = projectCapletsRoot @@ -1818,7 +1909,11 @@ export function loadLocalOverlayConfigWithSources( projectPath, "project-config", warnings, - (input) => rejectProjectConfigExecutableBackendMaps(input, projectPath), + (input) => + rejectProjectConfigExecutableBackendMaps( + stripProjectServeConfig(input, projectPath, warnings), + projectPath, + ), parseOptions, ) : undefined; @@ -2326,6 +2421,33 @@ function readPublicConfigInput(path: string): ConfigInput { } } +function readServeDefaultsInput(path: string): ServeConfig | undefined { + try { + const input = JSON.parse(readFileSync(path, "utf8")); + const validationOptions = { sources: {}, vaultResolver: vaultBootstrapResolver }; + const parsed = serveDefaultsFileSchema.safeParse( + interpolateConfig(input as ConfigInput, [], validationOptions), + ); + if (!parsed.success) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplets config at ${path} has invalid serve defaults`, + parsed.error.issues, + ); + } + return parsed.data.serve ? normalizeServeConfig(parsed.data.serve) : undefined; + } catch (error) { + if (error instanceof CapletsError) { + throw error; + } + throw new CapletsError( + "CONFIG_INVALID", + `Caplets config at ${path} is not valid JSON`, + redactSecrets(error), + ); + } +} + function normalizeLocalPaths(input: ConfigInput, baseDir: string): ConfigInput { return stripUndefined({ ...input, @@ -2498,6 +2620,7 @@ function mergeConfigInputs(...inputs: Array): ConfigInp ...merged, ...input, telemetry: input.telemetry === undefined ? merged?.telemetry : input.telemetry, + serve: input.serve === undefined ? merged?.serve : input.serve, namespaceAliases: mergeNamespaceAliases(merged?.namespaceAliases, input.namespaceAliases), mcpServers: { ...merged?.mcpServers, @@ -2586,7 +2709,25 @@ function mergeConfigInputsWithSources(...inputs: Array): ServeConfig { + return stripUndefined({ + host: raw.host, + port: raw.port, + path: raw.path, + remoteStatePath: raw.remoteStatePath, + upstreamUrl: raw.upstreamUrl, + allowUnauthenticatedHttp: raw.allowUnauthenticatedHttp, + trustProxy: raw.trustProxy, + publicOrigins: raw.publicOrigins, + }) as ServeConfig; +} + +function isAllowedServePublicOrigin(value: string): boolean { + if (!isAllowedHttpBaseUrl(value)) return false; + return new URL(value).pathname === "/"; +} + +function normalizePublicOrigin(value: string): string { + return new URL(value).origin; +} + function validateEndpointAuthHeaders( auth: OpenApiAuthConfig | undefined, ctx: z.RefinementCtx, diff --git a/packages/core/src/daemon/client-url.ts b/packages/core/src/daemon/client-url.ts new file mode 100644 index 00000000..d837a0f2 --- /dev/null +++ b/packages/core/src/daemon/client-url.ts @@ -0,0 +1,28 @@ +import { CapletsError } from "../errors"; +import { isLoopbackHost, parseServerBaseUrl } from "../server/options"; +import type { DaemonConfig } from "./types"; + +export function daemonClientBaseUrl(config: Pick): URL { + const host = daemonClientHost(config.serve.host); + return parseServerBaseUrl( + `http://${formatDaemonClientHost(host)}:${config.serve.port}${config.serve.path}`, + ); +} + +export function isWildcardBindHost(host: string): boolean { + const normalized = host.toLowerCase(); + return normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]"; +} + +function daemonClientHost(host: string): string { + if (isWildcardBindHost(host)) return "127.0.0.1"; + if (isLoopbackHost(host)) return host; + throw new CapletsError( + "REQUEST_INVALID", + `Default Caplets daemon client URL must use a loopback host; daemon is configured for ${host}.`, + ); +} + +function formatDaemonClientHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} diff --git a/packages/core/src/daemon/index.ts b/packages/core/src/daemon/index.ts index b3620adc..d1255144 100644 --- a/packages/core/src/daemon/index.ts +++ b/packages/core/src/daemon/index.ts @@ -8,7 +8,9 @@ import { writeFileSync, } from "node:fs"; import { dirname } from "node:path"; +import { loadGlobalServeDefaults } from "../config"; import { CapletsError } from "../errors"; +import { isWildcardBindHost } from "./client-url"; import { daemonHostPath } from "./host-path"; import { mergeDaemonEnv, @@ -59,7 +61,12 @@ export async function installDaemon( existing?.serve.publicOrigin && env.CAPLETS_SERVER_URL === undefined ? { ...env, CAPLETS_SERVER_URL: existing.serve.publicOrigin } : env; - const serve = resolveDaemonHttpServeOptions(mergeServeOptions(existing, install), serveEnv); + const serveOverrides = mergeServeOverrides(existing, install); + const serve = resolveDaemonHttpServeOptions( + serveOverrides, + serveEnv, + loadGlobalServeDefaults(env, options), + ); const command = buildDaemonCommandPlan({ serve, paths, @@ -70,6 +77,7 @@ export async function installDaemon( const config: DaemonConfig = { instance: "default", serve, + serveOverrides, command, env: daemonEnv, paths, @@ -254,11 +262,6 @@ function bindHostsMayOverlap(left: string, right: string): boolean { return left === right || isWildcardBindHost(left) || isWildcardBindHost(right); } -function isWildcardBindHost(host: string): boolean { - const normalized = host.toLowerCase(); - return normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]"; -} - async function waitForDaemonHealth( config: DaemonConfig, options: DaemonOperationOptions, @@ -415,6 +418,7 @@ export function redactDaemonInstallResult(result: DaemonInstallResult): DaemonIn } function redactDaemonConfig(config: DaemonConfig): DaemonConfig { + const secrets = collectDaemonSecrets(config); const env = redactEnv(config.env.values); const serve = redactLegacyDaemonServeAuth(config.serve); return { @@ -427,6 +431,14 @@ function redactDaemonConfig(config: DaemonConfig): DaemonConfig { ...serve, ...(config.serve.remoteCredentialStateDir ? { remoteCredentialStateDir: "[REDACTED]" } : {}), }, + ...(config.serveOverrides + ? { + serveOverrides: redactNativeValue( + config.serveOverrides, + secrets, + ) as DaemonConfig["serveOverrides"], + } + : {}), command: { ...config.command, args: redactSensitiveArgs(config.command.args), @@ -462,6 +474,7 @@ function collectDaemonSecrets(config: DaemonConfig): string[] { new Set( [ config.serve.remoteCredentialStateDir, + config.serveOverrides?.remoteStatePath, legacyDaemonServeAuth(config.serve)?.password, ...Object.values(config.env.values), ...Object.values(config.command.env), @@ -594,14 +607,39 @@ async function daemonLifecycle( options: DaemonOperationOptions, ): Promise { const paths = resolveDaemonPaths(options); - const config = readDaemonConfig(paths); - if (!config) { + const persisted = readDaemonConfig(paths); + const manager = options.manager ?? createNativeDaemonManager(options); + if (action === "stop") { + const before = await manager.status(persisted, paths); + if (before.state === "not_installed") { + throw new CapletsError( + "REQUEST_INVALID", + "Caplets daemon is not installed. Run caplets daemon install first.", + ); + } + const native = await manager.stop(persisted); + writeDaemonState(paths, { + instance: "default", + installed: native.native.installed, + running: native.native.running, + nativeState: native.native.state, + updatedAt: (options.now ?? new Date()).toISOString(), + ...(native.native.pid === undefined ? {} : { pid: native.native.pid }), + }); + const status = await daemonStatus({ ...options, manager }); + return { action, native, status }; + } + + if (!persisted) { throw new CapletsError( "REQUEST_INVALID", `Caplets daemon is not installed. Run caplets daemon install${action === "start" || action === "restart" ? " --start" : ""} first.`, ); } - const manager = options.manager ?? createNativeDaemonManager(options); + const config = refreshDaemonServeConfig(persisted, options); + if (config !== persisted) { + writeDaemonConfig(paths, config); + } const before = await manager.status(config, paths); if (before.state === "not_installed") { throw new CapletsError( @@ -611,11 +649,7 @@ async function daemonLifecycle( } const effectiveAction = action === "start" && before.running ? "restart" : action; const native = - effectiveAction === "start" - ? await manager.start(config) - : effectiveAction === "restart" - ? await manager.restart(config) - : await manager.stop(config); + effectiveAction === "start" ? await manager.start(config) : await manager.restart(config); writeDaemonState(paths, { instance: "default", installed: native.native.installed, @@ -635,51 +669,100 @@ async function daemonLifecycle( return { action: effectiveAction, native, status }; } -function mergeServeOptions( +function mergeServeOverrides( existing: DaemonConfig | undefined, install: DaemonInstallOptions, ): RawDaemonServeOptions { - return { - ...(install.host !== undefined - ? { host: install.host } - : existing?.serve.host - ? { host: existing.serve.host } - : {}), - ...(install.port !== undefined - ? { port: install.port } - : existing?.serve.port - ? { port: existing.serve.port } - : {}), - ...(install.path !== undefined - ? { path: install.path } - : existing?.serve.path - ? { path: existing.serve.path } - : {}), - ...(install.remoteStatePath !== undefined - ? { remoteStatePath: install.remoteStatePath } - : existing?.serve.remoteCredentialStateDir - ? { remoteStatePath: existing.serve.remoteCredentialStateDir } - : {}), - ...(install.upstreamUrl !== undefined - ? { upstreamUrl: install.upstreamUrl } - : existing?.serve.upstreamUrl - ? { upstreamUrl: existing.serve.upstreamUrl } - : {}), - ...(install.allowUnauthenticatedHttp !== undefined - ? { allowUnauthenticatedHttp: install.allowUnauthenticatedHttp } - : existing - ? { allowUnauthenticatedHttp: existing.serve.allowUnauthenticatedHttp } - : {}), - ...(install.trustProxy !== undefined - ? { trustProxy: install.trustProxy } - : existing - ? { trustProxy: existing.serve.trustProxy } - : {}), - ...(existing && + const existingOverrides = existing + ? (existing.serveOverrides ?? legacyServeOverrides(existing)) + : undefined; + const overrides: RawDaemonServeOptions = {}; + setDefinedServeOverride(overrides, "host", install.host ?? existingOverrides?.host); + setDefinedServeOverride(overrides, "port", install.port ?? existingOverrides?.port); + setDefinedServeOverride(overrides, "path", install.path ?? existingOverrides?.path); + setDefinedServeOverride( + overrides, + "remoteStatePath", + install.remoteStatePath ?? existingOverrides?.remoteStatePath, + ); + setDefinedServeOverride( + overrides, + "upstreamUrl", + install.upstreamUrl ?? existingOverrides?.upstreamUrl, + ); + setDefinedServeOverride( + overrides, + "allowUnauthenticatedHttp", + install.allowUnauthenticatedHttp ?? existingOverrides?.allowUnauthenticatedHttp, + ); + setDefinedServeOverride( + overrides, + "trustProxy", + install.trustProxy ?? existingOverrides?.trustProxy, + ); + if ( + existing && existing.serve.auth.type === "development_unauthenticated" && + existing.serveOverrides === undefined && install.allowUnauthenticatedHttp === undefined - ? { preserveUnauthenticatedAuth: true } + ) { + overrides.preserveUnauthenticatedAuth = true; + } + return overrides; +} + +function setDefinedServeOverride( + overrides: RawDaemonServeOptions, + key: K, + value: RawDaemonServeOptions[K] | undefined, +): void { + if (value !== undefined) { + overrides[key] = value; + } +} + +function legacyServeOverrides(existing: DaemonConfig): RawDaemonServeOptions { + return { + host: existing.serve.host, + port: existing.serve.port, + path: existing.serve.path, + ...(existing.serve.remoteCredentialStateDir + ? { remoteStatePath: existing.serve.remoteCredentialStateDir } : {}), + ...(existing.serve.upstreamUrl ? { upstreamUrl: existing.serve.upstreamUrl } : {}), + allowUnauthenticatedHttp: existing.serve.allowUnauthenticatedHttp, + trustProxy: existing.serve.trustProxy, + }; +} + +function refreshDaemonServeConfig( + config: DaemonConfig, + options: DaemonOperationOptions, +): DaemonConfig { + const env = options.env ?? process.env; + const serveEnv = + config.serve.publicOrigin && env.CAPLETS_SERVER_URL === undefined + ? { ...env, CAPLETS_SERVER_URL: config.serve.publicOrigin } + : env; + const serveOverrides = config.serveOverrides ?? legacyServeOverrides(config); + const serve = resolveDaemonHttpServeOptions( + serveOverrides, + serveEnv, + loadGlobalServeDefaults(env, options), + ); + const command = buildDaemonCommandPlan({ + serve, + paths: config.paths, + operation: options, + explicitEnv: config.env.values, + inheritEnv: config.env.inherit, + }); + return { + ...config, + serve, + serveOverrides, + command, + updatedAt: (options.now ?? new Date()).toISOString(), }; } @@ -743,6 +826,7 @@ export { resolveDaemonHttpServeOptions, daemonServeArgs } from "./process"; export { readDaemonConfig, readDaemonState } from "./config"; export { createNativeDaemonManager } from "./manager"; export { followDaemonLogs } from "./logs"; +export { daemonClientBaseUrl } from "./client-url"; export type { DaemonCommandPlan, DaemonCommandRunner, diff --git a/packages/core/src/daemon/process.ts b/packages/core/src/daemon/process.ts index 2761162e..6f714a51 100644 --- a/packages/core/src/daemon/process.ts +++ b/packages/core/src/daemon/process.ts @@ -3,7 +3,12 @@ import { homedir } from "node:os"; import { isAbsolute, resolve } from "node:path"; import { defaultConfigPath } from "../config/paths"; import { CapletsError } from "../errors"; -import { resolveServeOptions, type HttpServeOptions, type RawServeOptions } from "../serve/options"; +import { + resolveServeOptions, + type HttpServeOptions, + type RawServeOptions, + type ServeDefaults, +} from "../serve/options"; import { DISABLE_UPDATE_CHECK_ENV } from "../update-check/control"; import { resolveDaemonShell } from "./env"; import type { @@ -16,6 +21,7 @@ import type { export function resolveDaemonHttpServeOptions( raw: RawDaemonServeOptions, env: NodeJS.ProcessEnv | Record = process.env, + defaults?: ServeDefaults | undefined, ): HttpServeOptions { if ((raw as RawServeOptions).transport !== undefined) { throw new CapletsError( @@ -24,8 +30,14 @@ export function resolveDaemonHttpServeOptions( ); } const serveRaw = { ...raw }; + if ( + serveRaw.preserveUnauthenticatedAuth === true && + serveRaw.allowUnauthenticatedHttp === undefined + ) { + serveRaw.allowUnauthenticatedHttp = true; + } delete serveRaw.preserveUnauthenticatedAuth; - return resolveServeOptions({ ...serveRaw, transport: "http" }, env) as HttpServeOptions; + return resolveServeOptions({ ...serveRaw, transport: "http" }, env, defaults) as HttpServeOptions; } export function daemonServeArgs(options: HttpServeOptions): string[] { diff --git a/packages/core/src/daemon/types.ts b/packages/core/src/daemon/types.ts index 6618d37b..c4f45096 100644 --- a/packages/core/src/daemon/types.ts +++ b/packages/core/src/daemon/types.ts @@ -6,6 +6,8 @@ export type RawDaemonServeOptions = Omit & { preserveUnauthenticatedAuth?: boolean; }; +export type DaemonServeOverrides = RawDaemonServeOptions; + export type DaemonPaths = { instance: DaemonInstance; stateDir: string; @@ -43,6 +45,7 @@ export type DaemonCommandPlan = { export type DaemonConfig = { instance: DaemonInstance; serve: HttpServeOptions; + serveOverrides?: DaemonServeOverrides | undefined; command: DaemonCommandPlan; env: DaemonEnvConfig; paths: DaemonPaths; diff --git a/packages/core/src/native.ts b/packages/core/src/native.ts index 47eaa6b2..0fb49564 100644 --- a/packages/core/src/native.ts +++ b/packages/core/src/native.ts @@ -21,13 +21,21 @@ export { generatedToolInputSchema } from "./tools"; export { generatedToolInputJsonSchema } from "./generated-tool-input-schema"; export { resolveNativeCapletsServiceOptions, + hasNativeRuntimeSelectionEnv, type NativeCapletsEnv, type NativeCapletsMode, + type NativeDaemonCapletsOptions, type NativeCapletsServiceResolutionInput, type NativeRemoteAuthOptions, type NativeRemoteCapletsOptions, type ResolvedNativeCapletsServiceOptions, } from "./native/options"; +export { + nativeDefaultsPath, + readNativeDefaults, + writeNativeDefaults, + type NativeDefaults, +} from "./native/user-settings"; export { createSdkRemoteCapletsClient, RemoteNativeCapletsService, diff --git a/packages/core/src/native/options.ts b/packages/core/src/native/options.ts index 5e853dfa..00244b55 100644 --- a/packages/core/src/native/options.ts +++ b/packages/core/src/native/options.ts @@ -7,8 +7,9 @@ import { type CapletsRemoteAuth, type CapletsRemoteEnv, } from "../remote/options"; +import { isLoopbackHost } from "../server/options"; -type CapletsMode = "auto" | "local" | "remote" | "cloud"; +type CapletsMode = "auto" | "local" | "remote" | "cloud" | "daemon"; export type NativeCapletsMode = CapletsMode; @@ -21,6 +22,13 @@ export type NativeRemoteCapletsOptions = { cloud?: NativeCloudPresenceInput; }; +export type NativeDaemonCapletsOptions = { + url?: string; + fetch?: typeof fetch; + requestHeaders?: Record; + pollIntervalMs?: number; +}; + export type NativeCloudPresenceInput = { url?: string; accessToken?: string; @@ -32,19 +40,35 @@ export type NativeCloudPresenceInput = { export type NativeCapletsServiceResolutionInput = { mode?: NativeCapletsMode; remote?: NativeRemoteCapletsOptions; + daemon?: NativeDaemonCapletsOptions; }; export type NativeCapletsEnv = CapletsRemoteEnv & Partial< Record< + | "CAPLETS_MODE" | "CAPLETS_CLOUD_URL" | "CAPLETS_CLOUD_TOKEN" | "CAPLETS_CLOUD_WORKSPACE_ID" - | "CAPLETS_PROJECT_ROOT", + | "CAPLETS_PROJECT_ROOT" + | "CAPLETS_DAEMON_URL", string > >; +const NATIVE_RUNTIME_SELECTION_ENV_KEYS: Array = [ + "CAPLETS_MODE", + "CAPLETS_REMOTE_URL", + "CAPLETS_DAEMON_URL", + "CAPLETS_CLOUD_URL", + "CAPLETS_CLOUD_TOKEN", + "CAPLETS_CLOUD_WORKSPACE_ID", +]; + +export function hasNativeRuntimeSelectionEnv(env: NativeCapletsEnv = process.env): boolean { + return NATIVE_RUNTIME_SELECTION_ENV_KEYS.some((key) => Boolean(env[key]?.trim())); +} + export type NativeRemoteAuthOptions = | { enabled: false; user: string } | { enabled: true; user: string; password: string }; @@ -52,7 +76,7 @@ export type NativeRemoteAuthOptions = export type ResolvedNativeCapletsServiceOptions = | { mode: "local" } | { - mode: "remote" | "cloud"; + mode: "remote" | "cloud" | "daemon"; remote: { url: URL; auth: NativeRemoteAuthOptions; @@ -70,6 +94,15 @@ export function resolveNativeCapletsServiceOptions( input: NativeCapletsServiceResolutionInput = {}, env: NativeCapletsEnv = process.env, ): ResolvedNativeCapletsServiceOptions { + const explicitMode = input.mode ?? env.CAPLETS_MODE; + const daemonUrl = input.daemon?.url ?? env.CAPLETS_DAEMON_URL; + if (explicitMode === "daemon") { + return resolveNativeDaemonOptions(input, env); + } + if ((explicitMode === undefined || explicitMode === "auto") && daemonUrl?.trim()) { + return resolveNativeDaemonOptions(input, env); + } + const mode = resolveRemoteMode( { ...(input.mode ? { mode: input.mode } : {}), @@ -116,6 +149,39 @@ export function resolveNativeCapletsServiceOptions( }; } +function resolveNativeDaemonOptions( + input: NativeCapletsServiceResolutionInput, + env: NativeCapletsEnv, +): ResolvedNativeCapletsServiceOptions { + const daemonUrl = input.daemon?.url ?? env.CAPLETS_DAEMON_URL; + if (!daemonUrl) { + throw new CapletsError("REQUEST_INVALID", "Native daemon mode requires daemon.url."); + } + const server = resolveCapletsRemote( + { + url: daemonUrl, + ...(input.daemon?.fetch ? { fetch: input.daemon.fetch } : {}), + }, + {}, + ); + if (server.baseUrl.protocol !== "http:" || !isLoopbackHost(server.baseUrl.hostname)) { + throw new CapletsError( + "REQUEST_INVALID", + "Native daemon mode requires a loopback HTTP daemon URL.", + ); + } + return { + mode: "daemon", + remote: { + url: server.attachUrl, + auth: nativeAuthFromRemoteAuth(server.auth), + pollIntervalMs: parsePollInterval(input.daemon?.pollIntervalMs), + requestInit: withRequestHeaders(server.requestInit, input.daemon?.requestHeaders), + ...(server.fetch ? { fetch: server.fetch } : {}), + }, + }; +} + function withRequestHeaders( requestInit: RequestInit, requestHeaders: Record | undefined, diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index 78364902..0824623a 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -166,6 +166,16 @@ export function createNativeCapletsService( if (resolved.mode === "cloud") { return new ProfileBackedNativeCapletsService(options, resolved.remote, "hosted_cloud"); } + if (resolved.mode === "daemon") { + const remote = new RemoteNativeCapletsService({ + client: createRemoteClient(resolved.remote, options, "self_hosted_remote"), + clientFactory: () => createRemoteClient(resolved.remote, options, "self_hosted_remote"), + pollIntervalMs: resolved.remote.pollIntervalMs, + authKind: "self_hosted_remote", + ...(options.writeErr ? { writeErr: options.writeErr } : {}), + }); + return remote; + } return new DefaultNativeCapletsService(options); } @@ -601,6 +611,7 @@ function runtimeModeFromNativeOptions(options: NativeCapletsServiceOptions) { if (options.mode === "local") return "local"; if (options.mode === "remote") return "remote"; if (options.mode === "cloud") return "cloud"; + if (options.mode === "daemon") return "remote"; if (options.remote?.url) return "remote"; const envMode = options.telemetryEnv?.CAPLETS_MODE ?? process.env.CAPLETS_MODE; if (envMode === "remote" || envMode === "cloud" || envMode === "local") return envMode; @@ -1040,7 +1051,7 @@ function remoteOptionsFromSelection( requestInit: selection.remote.requestInit, ...(selection.remote.fetch ? { fetch: selection.remote.fetch } : {}), ...(cloudPresence ? { cloud: cloudPresence } : {}), - ...(selection.credentialExpiresAt + ...("credentialExpiresAt" in selection && selection.credentialExpiresAt ? { credentialExpiresAt: selection.credentialExpiresAt } : {}), } satisfies ProfileResolvedNativeRemoteOptions; diff --git a/packages/core/src/native/user-settings.ts b/packages/core/src/native/user-settings.ts new file mode 100644 index 00000000..158fc002 --- /dev/null +++ b/packages/core/src/native/user-settings.ts @@ -0,0 +1,82 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { resolveCapletsRoot, resolveConfigPath } from "../config"; +import { isLoopbackHost, parseServerBaseUrl } from "../server/options"; + +export type NativeDefaults = { + version: 1; + source: "setup"; + updatedAt: string; + daemon: { url: string }; +}; + +export type NativeDefaultsInput = { + source: "setup"; + daemon: { url: string }; +}; + +export type NativeDefaultsPathOptions = { + path?: string; + configPath?: string; + env?: Record | NodeJS.ProcessEnv; +}; + +export function nativeDefaultsPath(options: NativeDefaultsPathOptions = {}): string { + if (options.path) return options.path; + const configPath = options.configPath ?? options.env?.CAPLETS_CONFIG ?? resolveConfigPath(); + return join(resolveCapletsRoot(configPath), "native-defaults.json"); +} + +export function writeNativeDefaults( + input: NativeDefaultsInput, + options: NativeDefaultsPathOptions & { now?: Date } = {}, +): string { + const path = nativeDefaultsPath(options); + const defaults: NativeDefaults = { + version: 1, + source: input.source, + updatedAt: (options.now ?? new Date()).toISOString(), + daemon: { url: input.daemon.url }, + }; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`, { mode: 0o600 }); + return path; +} + +export function readNativeDefaults( + options: NativeDefaultsPathOptions & { writeWarning?: (message: string) => void } = {}, +): NativeDefaults | undefined { + const path = nativeDefaultsPath(options); + if (!existsSync(path)) return undefined; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + if (!isNativeDefaults(parsed)) throw new Error("invalid native defaults shape"); + validateNativeDefaultsDaemonUrl(parsed.daemon.url); + return parsed; + } catch (error) { + options.writeWarning?.( + `Ignoring Caplets native defaults at ${path}: ${error instanceof Error ? error.message : "invalid file"}`, + ); + return undefined; + } +} + +function validateNativeDefaultsDaemonUrl(value: string): void { + const url = parseServerBaseUrl(value); + if (url.protocol !== "http:" || !isLoopbackHost(url.hostname)) { + throw new Error("daemon.url must be a loopback HTTP URL"); + } +} + +function isNativeDefaults(value: unknown): value is NativeDefaults { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + const daemon = record.daemon as Record | undefined; + return ( + record.version === 1 && + record.source === "setup" && + typeof record.updatedAt === "string" && + Boolean(daemon) && + typeof daemon?.url === "string" + ); +} diff --git a/packages/core/src/project-binding/attach.ts b/packages/core/src/project-binding/attach.ts index 39f4710d..f7b49ba8 100644 --- a/packages/core/src/project-binding/attach.ts +++ b/packages/core/src/project-binding/attach.ts @@ -27,7 +27,7 @@ export type ResolvedAttachOptions = { verbose: boolean; once: boolean; remote: ResolvedCapletsRemote; - authMode: "self_hosted_remote" | "hosted_cloud"; + authMode: "local_daemon" | "self_hosted_remote" | "hosted_cloud"; syncPolicy: MutagenSyncPolicy; selectedWorkspace?: string | undefined; }; @@ -107,12 +107,16 @@ export async function attachProjectSession( const pinnedRaw = resolved.selectedWorkspace ? { ...raw, workspace: resolved.selectedWorkspace } : raw; + const remoteResolver = + resolved.authMode === "local_daemon" + ? undefined + : async () => (await resolveAttachOptionsForRun(pinnedRaw, env)).remote; bootstrapProjectBindingGitignore(resolved.projectRoot); assertSyncPolicy(resolved.syncPolicy); return await runProjectBindingSession({ projectRoot: resolved.projectRoot, remote: resolved.remote, - remoteResolver: async () => (await resolveAttachOptionsForRun(pinnedRaw, env)).remote, + ...(remoteResolver ? { remoteResolver } : {}), fetch: resolved.remote.fetch, signal: options.signal, heartbeatIntervalMs: options.heartbeatIntervalMs, diff --git a/packages/core/src/remote/selection.ts b/packages/core/src/remote/selection.ts index 7fa39558..2d069406 100644 --- a/packages/core/src/remote/selection.ts +++ b/packages/core/src/remote/selection.ts @@ -1,9 +1,19 @@ import { CloudAuthClient } from "../cloud-auth/client"; import type { CloudAuthCredentials } from "../cloud-auth/store"; import { HOSTED_CLOUD_AUTH_SCOPES } from "../cloud-auth/types"; +import { daemonClientBaseUrl } from "../daemon/client-url"; +import { readDaemonConfig } from "../daemon/config"; +import { createNativeDaemonManager } from "../daemon/manager"; +import { resolveDaemonPaths } from "../daemon/paths"; +import type { DaemonConfig, DaemonOperationOptions } from "../daemon/types"; import { CapletsError } from "../errors"; import { ProjectBindingError, projectBindingError } from "../project-binding/errors"; -import { appendBasePath } from "../server/options"; +import { + appendBasePath, + healthUrlForBase, + isLoopbackHost, + parseServerBaseUrl, +} from "../server/options"; import { hostedCloudWorkspaceFromRemoteUrl, normalizeRemoteProfileHostUrl, @@ -24,7 +34,15 @@ export type RemoteSelectionInput = { authDir?: string; }; +type RemoteSelectionDependencies = { + daemon?: Omit; +}; + export type ResolvedRemoteSelection = + | { + kind: "local_daemon"; + remote: ResolvedCapletsRemote; + } | { kind: "self_hosted_remote"; remote: ResolvedCapletsRemote; @@ -46,6 +64,7 @@ export type ResolvedRemoteSelection = export async function resolveRemoteSelection( input: RemoteSelectionInput = {}, env: Record = process.env, + dependencies: RemoteSelectionDependencies = {}, ): Promise { const modeValue = input.mode ?? env.CAPLETS_MODE; const mode = resolveRemoteMode( @@ -68,6 +87,7 @@ export async function resolveRemoteSelection( if (!remoteUrl) { throw new CapletsError("REQUEST_INVALID", "CAPLETS_REMOTE_URL or remoteUrl is required."); } + const localDaemonFallback = isLocalDaemonRemoteUrl(remoteUrl); const store = createRemoteProfileStore({ authDir: input.authDir, env }); const refreshed = await store.refreshSelfHostedProfileIfNeeded({ hostUrl: remoteUrl, @@ -97,6 +117,13 @@ export async function resolveRemoteSelection( }); const credential = refreshed?.credential; if (!credential?.accessToken) { + if ( + localDaemonFallback && + !refreshed && + (await isSetupValidatedLocalDaemon(remoteUrl, input, env, dependencies)) + ) { + return localDaemonRemoteSelection(remoteUrl, input.fetch); + } const normalizedUrl = normalizeRemoteProfileHostUrl(remoteUrl); throw new ProjectBindingError({ code: "remote_credentials_required", @@ -251,6 +278,70 @@ export async function resolveRemoteSelection( }; } +function localDaemonRemoteSelection( + remoteUrl: string, + fetch: typeof globalThis.fetch | undefined, +): ResolvedRemoteSelection { + return { + kind: "local_daemon", + remote: resolveCapletsRemote( + { + url: remoteUrl, + ...(fetch !== undefined ? { fetch } : {}), + }, + {}, + ), + }; +} + +function isLocalDaemonRemoteUrl(value: string): boolean { + const url = parseServerBaseUrl(value); + return url.protocol === "http:" && isLoopbackHost(url.hostname); +} + +async function isSetupValidatedLocalDaemon( + value: string, + input: RemoteSelectionInput, + env: Record, + dependencies: RemoteSelectionDependencies, +): Promise { + try { + const requested = parseServerBaseUrl(value); + const daemonOptions: DaemonOperationOptions = { + ...dependencies.daemon, + env, + ...(input.fetch ? { fetch: input.fetch } : {}), + }; + const paths = resolveDaemonPaths(daemonOptions); + const config = readDaemonConfig(paths); + if (!config || daemonClientBaseUrl(config).href !== requested.href) return false; + const native = await (daemonOptions.manager ?? createNativeDaemonManager(daemonOptions)).status( + config, + paths, + ); + if (!native.running) return false; + return await isDaemonHealthOk(config, input.fetch); + } catch { + return false; + } +} + +async function isDaemonHealthOk( + config: Pick, + fetchInput: typeof globalThis.fetch | undefined, +): Promise { + const fetchImpl = fetchInput ?? globalThis.fetch; + if (!fetchImpl) return false; + try { + const response = await fetchImpl(healthUrlForBase(daemonClientBaseUrl(config)), { + signal: AbortSignal.timeout(2_000), + }); + return response.ok; + } catch { + return false; + } +} + function credentialsNeedRefresh(credentials: { expiresAt: string }): boolean { const expiresAt = Date.parse(credentials.expiresAt); return Number.isFinite(expiresAt) && expiresAt <= Date.now() + 60_000; diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index 0f657771..b3359a7f 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -206,12 +206,8 @@ export function createHttpServeApp( const parsed = await parseJsonObject(c.req.json(), "Pending remote login start request"); const clientLabel = optionalStringField(parsed, "clientLabel"); const clientFingerprint = optionalStringField(parsed, "clientFingerprint"); - const hostUrl = remoteCredentialHostUrl( - c.req.url, - paths.base, - options.publicOrigin, - options.trustProxy, - (name) => c.req.header(name), + const hostUrl = remoteCredentialHostUrl(c.req.url, paths.base, options, (name) => + c.req.header(name), ); const pending = remoteCredentialStore.createPendingLogin({ hostUrl, @@ -259,12 +255,8 @@ export function createHttpServeApp( try { const parsed = await parseJsonObject(c.req.json(), "Pending remote login complete request"); const credentials = remoteCredentialStore.completePendingLogin({ - hostUrl: remoteCredentialHostUrl( - c.req.url, - paths.base, - options.publicOrigin, - options.trustProxy, - (name) => c.req.header(name), + hostUrl: remoteCredentialHostUrl(c.req.url, paths.base, options, (name) => + c.req.header(name), ), flowId: stringField(parsed, "flowId"), pendingCompletionSecret: stringField(parsed, "pendingCompletionSecret"), @@ -298,12 +290,8 @@ export function createHttpServeApp( const parsed = await parseJsonObject(c.req.json(), "Remote refresh request"); const refreshToken = stringField(parsed, "refreshToken"); const credentials = remoteCredentialStore.refreshClientCredentials({ - hostUrl: remoteCredentialHostUrl( - c.req.url, - paths.base, - options.publicOrigin, - options.trustProxy, - (name) => c.req.header(name), + hostUrl: remoteCredentialHostUrl(c.req.url, paths.base, options, (name) => + c.req.header(name), ), refreshToken, }); @@ -1053,17 +1041,46 @@ function publicHostUrl( function remoteCredentialHostUrl( requestUrl: string, basePath: string, - publicOrigin: string | undefined, - trustProxy: boolean, + options: Pick, header: (name: string) => string | undefined, ): string { - if (trustProxy && !publicOrigin) { + const publicOrigin = remoteCredentialPublicOrigin(options, header); + if (options.trustProxy && !publicOrigin) { throw new CapletsError( "REQUEST_INVALID", - "Remote credential auth with --trust-proxy requires CAPLETS_SERVER_URL.", + "Remote credential auth with --trust-proxy requires a configured public origin.", ); } - return publicHostUrl(requestUrl, basePath, publicOrigin, trustProxy, header); + return publicHostUrl(requestUrl, basePath, publicOrigin, options.trustProxy, header); +} + +function remoteCredentialPublicOrigin( + options: Pick, + header: (name: string) => string | undefined, +): string | undefined { + const publicOrigins = publicOriginsForOptions(options); + if (publicOrigins.length === 0) return undefined; + const host = + firstForwardedValue(options.trustProxy ? header("x-forwarded-host") : undefined) ?? + header("host"); + const matchingOrigin = host + ? publicOrigins.find((origin) => publicOriginMatchesHost(origin, host)) + : undefined; + return matchingOrigin ?? options.publicOrigin ?? publicOrigins[0]; +} + +function publicOriginMatchesHost(origin: string, host: string): boolean { + const url = new URL(origin); + const normalizedHost = normalizeHostHeader(host); + const originHost = normalizeHostHeader(url.host); + const originHostWithDefaultPort = normalizeHostHeader( + `${url.hostname}:${url.protocol === "https:" ? "443" : "80"}`, + ); + return normalizedHost === originHost || normalizedHost === originHostWithDefaultPort; +} + +function normalizeHostHeader(host: string): string { + return host.trim().toLowerCase(); } function remoteCredentialSourceHint( @@ -1113,13 +1130,7 @@ function remoteHostMetadata( options: HttpServeOptions, header: (name: string) => string | undefined, ): RemoteHostMetadata { - const audience = remoteCredentialHostUrl( - requestUrl, - basePath, - options.publicOrigin, - options.trustProxy, - header, - ); + const audience = remoteCredentialHostUrl(requestUrl, basePath, options, header); return { hostIdentity: audience, audience }; } @@ -1588,12 +1599,8 @@ function routeAuth( } try { remoteCredentialStore.validateAccessToken({ - hostUrl: remoteCredentialHostUrl( - c.req.url, - basePath, - options.publicOrigin, - options.trustProxy, - (name) => c.req.header(name), + hostUrl: remoteCredentialHostUrl(c.req.url, basePath, options, (name) => + c.req.header(name), ), accessToken: token, }); @@ -1617,13 +1624,7 @@ function validatedRemoteClient( throw new CapletsError("AUTH_FAILED", "Remote client credential is required."); } return remoteCredentialStore.validateAccessToken({ - hostUrl: remoteCredentialHostUrl( - requestUrl, - basePath, - options.publicOrigin, - options.trustProxy, - header, - ), + hostUrl: remoteCredentialHostUrl(requestUrl, basePath, options, header), accessToken: token, }); } @@ -1795,10 +1796,11 @@ type DnsRebindingOptions = { function dnsRebindingOptions(options: HttpServeOptions): DnsRebindingOptions { const hostForHeader = options.host === "::1" ? "[::1]" : options.host; - const publicUrl = options.publicOrigin ? new URL(options.publicOrigin) : undefined; + const publicUrls = publicOriginsForOptions(options).map((origin) => new URL(origin)); const publicHosts = - publicUrl && (options.auth.type === "remote_credentials" || options.allowUnauthenticatedHttp) - ? [publicUrl.hostname, publicUrl.host] + publicUrls.length > 0 && + (options.auth.type === "remote_credentials" || options.allowUnauthenticatedHttp) + ? publicUrls.flatMap((url) => [url.hostname, url.host]) : []; return { enableDnsRebindingProtection: true, @@ -1812,6 +1814,13 @@ function dnsRebindingOptions(options: HttpServeOptions): DnsRebindingOptions { }; } +function publicOriginsForOptions( + options: Pick, +): string[] { + if (options.publicOrigins?.length) return options.publicOrigins; + return options.publicOrigin ? [options.publicOrigin] : []; +} + function authDescription(options: HttpServeOptions): string { return options.auth.type === "remote_credentials" ? "remote credentials" diff --git a/packages/core/src/serve/options.ts b/packages/core/src/serve/options.ts index b3491120..fa0c552d 100644 --- a/packages/core/src/serve/options.ts +++ b/packages/core/src/serve/options.ts @@ -2,6 +2,7 @@ import { CapletsError } from "../errors"; import { isLoopbackHost, parseServerBaseUrl } from "../server/options"; import { DEFAULT_AUTH_DIR } from "../config/paths"; import { join } from "node:path"; +import type { ServeConfig } from "../config"; export type ServeTransport = "stdio" | "http"; @@ -26,6 +27,7 @@ export type HttpServeOptions = { port: number; path: string; publicOrigin?: string | undefined; + publicOrigins?: string[] | undefined; auth: HttpServeAuthOptions; remoteCredentialStateDir?: string | undefined; upstreamUrl?: string | undefined; @@ -45,6 +47,8 @@ export type ServeEnv = Partial< Record<"CAPLETS_SERVER_URL" | "CAPLETS_REMOTE_SERVER_STATE_DIR", string> >; +export type ServeDefaults = Partial; + const HTTP_ONLY_OPTIONS = [ "host", "port", @@ -68,6 +72,7 @@ const HTTP_ONLY_OPTION_FLAGS = { export function resolveServeOptions( raw: RawServeOptions, env: ServeEnv = process.env, + defaults?: ServeDefaults | undefined, ): ServeOptions { const transport = parseTransport(raw.transport ?? "stdio"); if (transport === "stdio") { @@ -84,17 +89,28 @@ export function resolveServeOptions( const serverUrl = env.CAPLETS_SERVER_URL ? parseServeServerUrl(nonEmpty(env.CAPLETS_SERVER_URL, "CAPLETS_SERVER_URL")!) : undefined; - const host = nonEmpty(raw.host, "--host") ?? serverUrlHost(serverUrl) ?? "127.0.0.1"; - const port = parsePort(raw.port ?? (serverUrl?.port ? Number(serverUrl.port) : 5387)); - const path = normalizeHttpPath(raw.path ?? serverUrl?.pathname ?? "/"); + const configuredPublicOrigins = defaults?.publicOrigins ?? []; + const publicOrigin = serverUrl?.origin ?? configuredPublicOrigins[0]; + const publicOrigins = publicOrigin + ? [publicOrigin, ...configuredPublicOrigins.filter((origin) => origin !== publicOrigin)] + : configuredPublicOrigins; + const host = + nonEmpty(raw.host, "--host") ?? serverUrlHost(serverUrl) ?? defaults?.host ?? "127.0.0.1"; + const port = parsePort( + raw.port ?? (serverUrl?.port ? Number(serverUrl.port) : (defaults?.port ?? 5387)), + ); + const path = normalizeHttpPath(raw.path ?? serverUrl?.pathname ?? defaults?.path ?? "/"); const remoteCredentialStateDir = nonEmpty(raw.remoteStatePath, "--remote-state-path") ?? nonEmpty(env.CAPLETS_REMOTE_SERVER_STATE_DIR, "CAPLETS_REMOTE_SERVER_STATE_DIR") ?? + nonEmpty(defaults?.remoteStatePath, "serve.remoteStatePath") ?? join(DEFAULT_AUTH_DIR, "remote-server"); - const upstreamUrl = nonEmpty(raw.upstreamUrl, "--upstream-url"); + const upstreamUrl = + nonEmpty(raw.upstreamUrl, "--upstream-url") ?? + nonEmpty(defaults?.upstreamUrl, "serve.upstreamUrl"); if (upstreamUrl) { rejectSelfReferentialUpstream(upstreamUrl, { - ...(serverUrl ? { origin: serverUrl.origin } : {}), + ...(publicOrigin ? { origin: publicOrigin } : {}), host, port, path, @@ -102,23 +118,27 @@ export function resolveServeOptions( } const loopback = isLoopbackHost(host); + const allowUnauthenticatedHttp = + raw.allowUnauthenticatedHttp ?? defaults?.allowUnauthenticatedHttp ?? false; const auth: HttpServeAuthOptions = - raw.allowUnauthenticatedHttp === true + allowUnauthenticatedHttp === true ? { type: "development_unauthenticated" } : { type: "remote_credentials" }; + const trustProxy = raw.trustProxy ?? defaults?.trustProxy ?? false; return { transport, host, port, path, - ...(serverUrl ? { publicOrigin: serverUrl.origin } : {}), + ...(publicOrigin ? { publicOrigin } : {}), + ...(publicOrigins.length > 0 ? { publicOrigins } : {}), auth, ...(auth.type === "remote_credentials" ? { remoteCredentialStateDir } : {}), ...(upstreamUrl ? { upstreamUrl } : {}), - allowUnauthenticatedHttp: raw.allowUnauthenticatedHttp === true, + allowUnauthenticatedHttp, warnUnauthenticatedNetwork: !loopback && auth.type === "development_unauthenticated", loopback, - trustProxy: raw.trustProxy === true, + trustProxy, }; } diff --git a/packages/core/test/add-mcp-adapter.test.ts b/packages/core/test/add-mcp-adapter.test.ts new file mode 100644 index 00000000..08d54311 --- /dev/null +++ b/packages/core/test/add-mcp-adapter.test.ts @@ -0,0 +1,31 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { listSupportedAddMcpClients, upsertCapletsMcpServer } from "../src/cli/add-mcp-adapter"; + +describe("add-mcp adapter", () => { + it("exposes canonical supported MCP client IDs from the pinned add-mcp contract", () => { + const ids = listSupportedAddMcpClients().map((client) => client.id); + + expect(ids).toEqual([...new Set(ids)]); + expect(ids).toEqual(expect.arrayContaining(["codex", "claude-code", "opencode"])); + }); + + it("upserts the Caplets server into disposable config only", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-mcp-contract-")); + try { + const result = await upsertCapletsMcpServer({ + clientId: "codex", + daemonBaseUrl: "http://127.0.0.1:5387/caplets", + cwd: dir, + }); + + expect(result).toMatchObject({ success: true, clientId: "codex" }); + expect(result.path).toContain(dir); + expect(result.error).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/test/agent-plugins.test.ts b/packages/core/test/agent-plugins.test.ts index e4ef6aa8..4dafa9f4 100644 --- a/packages/core/test/agent-plugins.test.ts +++ b/packages/core/test/agent-plugins.test.ts @@ -18,22 +18,25 @@ describe("Codex and Claude manual MCP setup", () => { } }); - it("documents manual MCP config for Codex and Claude users", async () => { + it("documents daemon-first local MCP setup for Codex and Claude users", async () => { const readme = await readFile(path.join(repoRoot, "README.md"), "utf8"); + expect(readme).toContain("caplets setup mcp-client --client codex"); + expect(readme).toContain("local Caplets daemon"); expect(readme).toContain('"command": "caplets"'); - expect(readme).toContain('"args": ["serve"]'); + expect(readme).toContain('"args": ["attach", ""]'); expect(readme).toContain('"args": ["attach", "https://caplets.example.com/caplets"]'); expect(readme).toContain("[mcp_servers.caplets]"); - expect(readme).toContain("codex mcp add caplets -- caplets serve"); - expect(readme).toContain( + expect(readme).not.toContain("codex mcp add caplets -- caplets serve"); + expect(readme).not.toContain( "claude mcp add --transport stdio --scope user caplets -- caplets serve", ); expect(readme).not.toMatch(/plugin marketplace add|plugin (?:add|install) caplets@caplets/u); }); - it("documents serve for local MCP and attach for remote MCP", async () => { + it("documents serve as an advanced fallback and attach for remote MCP", async () => { const readme = await readFile(path.join(repoRoot, "README.md"), "utf8"); - expect(readme).toContain("caplets serve"); + expect(readme).toContain("advanced manual fallback"); + expect(readme).toContain("caplets serve --transport http"); expect(readme).toContain("caplets attach"); expect(readme).toContain("caplets remote login https://caplets.example.com/caplets"); expect(readme).toContain("CAPLETS_MODE=cloud"); diff --git a/packages/core/test/attach-service-wiring.test.ts b/packages/core/test/attach-service-wiring.test.ts index 8e5f561e..9f022090 100644 --- a/packages/core/test/attach-service-wiring.test.ts +++ b/packages/core/test/attach-service-wiring.test.ts @@ -69,4 +69,47 @@ describe("attach native service wiring", () => { }), ); }); + it("wires local daemon selections without Cloud workspace or Remote Profile fields", async () => { + const { createAttachNativeServiceForTests } = await import("../src/attach/server"); + const selection: ResolvedRemoteSelection = { + kind: "local_daemon", + remote: { + baseUrl: new URL("http://127.0.0.1:5387/caplets"), + mcpUrl: new URL("http://127.0.0.1:5387/caplets/v1/mcp"), + attachUrl: new URL("http://127.0.0.1:5387/caplets/v1/attach"), + controlUrl: new URL("http://127.0.0.1:5387/caplets/v1/admin"), + healthUrl: new URL("http://127.0.0.1:5387/caplets/v1/healthz"), + projectBindingWebSocketUrl: new URL( + "ws://127.0.0.1:5387/caplets/v1/attach/project-bindings/connect", + ), + auth: { type: "none", user: "caplets" }, + requestInit: {}, + }, + }; + + createAttachNativeServiceForTests( + { + transport: "stdio", + configPath: "/repo/caplets.json", + projectRoot: "/repo", + projectConfigPath: "/repo/.caplets/config.json", + selection, + } as AttachServeOptions, + {}, + ); + + expect(createNativeCapletsService).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "remote", + remote: expect.objectContaining({ + url: "http://127.0.0.1:5387/caplets", + }), + }), + ); + expect(createNativeCapletsService).toHaveBeenCalledWith( + expect.not.objectContaining({ + remote: expect.objectContaining({ workspace: expect.anything() }), + }), + ); + }); }); diff --git a/packages/core/test/cli-completion.test.ts b/packages/core/test/cli-completion.test.ts index 9ee77ff3..643532b4 100644 --- a/packages/core/test/cli-completion.test.ts +++ b/packages/core/test/cli-completion.test.ts @@ -118,6 +118,9 @@ describe("CLI completion resolver", () => { "plain", "json", ]); + await expect(completeCliWords(["setup", "mcp-client", "--client", ""])).resolves.toEqual( + expect.arrayContaining(["codex", "claude-code", "opencode"]), + ); await expect(completeCliWords(["call-tool", "github.search", "--format", ""])).resolves.toEqual( ["markdown", "md", "plain", "json"], ); diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index 38afc695..840f6177 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -592,6 +592,110 @@ describe("cli init", () => { ]); }); + it("resolves HTTP serve with global config defaults", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-serve-config-defaults-")); + const configPath = join(dir, "config.json"); + const served: unknown[] = []; + try { + writeFileSync( + configPath, + JSON.stringify({ + serve: { + host: "0.0.0.0", + port: 5480, + path: "/configured", + allowUnauthenticatedHttp: true, + }, + }), + ); + process.env.CAPLETS_CONFIG = configPath; + delete process.env.CAPLETS_SERVER_URL; + + await runCli(["serve", "--transport", "http"], { + writeOut: () => {}, + serve: async (options) => { + served.push(options); + }, + }); + + expect(served).toEqual([ + expect.objectContaining({ + transport: "http", + host: "0.0.0.0", + port: 5480, + path: "/configured", + auth: { type: "development_unauthenticated" }, + }), + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("resolves HTTP serve defaults from the default user config path", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-serve-default-path-")); + const configPath = join(dir, "config", "caplets", "config.json"); + const served: unknown[] = []; + try { + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify({ serve: { port: 5481, path: "/default-path" } })); + delete process.env.CAPLETS_CONFIG; + delete process.env.CAPLETS_SERVER_URL; + + await runCli(["serve", "--transport", "http"], { + env: { ...process.env, CAPLETS_CONFIG: undefined, XDG_CONFIG_HOME: join(dir, "config") }, + writeOut: () => {}, + serve: async (options) => { + served.push(options); + }, + }); + + expect(served).toEqual([ + expect.objectContaining({ + transport: "http", + port: 5481, + path: "/default-path", + }), + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("lets HTTP serve CLI flags override global config defaults", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-serve-config-cli-")); + const configPath = join(dir, "config.json"); + const served: unknown[] = []; + try { + writeFileSync( + configPath, + JSON.stringify({ + serve: { host: "0.0.0.0", port: 5480, path: "/configured" }, + }), + ); + process.env.CAPLETS_CONFIG = configPath; + delete process.env.CAPLETS_SERVER_URL; + + await runCli(["serve", "--transport", "http", "--port", "6000", "--path", "/cli"], { + writeOut: () => {}, + serve: async (options) => { + served.push(options); + }, + }); + + expect(served).toEqual([ + expect.objectContaining({ + transport: "http", + host: "0.0.0.0", + port: 6000, + path: "/cli", + }), + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("resolves HTTP serve with an upstream URL", async () => { const served: unknown[] = []; @@ -1111,7 +1215,7 @@ describe("cli init", () => { await new FileRemoteProfileStore({ root: join(authDir, "remote-profiles"), }).saveSelfHostedProfile({ - hostUrl: "http://127.0.0.1:5387", + hostUrl: "https://remote.caplets.test/caplets", clientId: "rcli_test", clientLabel: "Remote Prompt Test", credentials: { @@ -1134,7 +1238,7 @@ describe("cli init", () => { { env: { CAPLETS_MODE: "remote", - CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", + CAPLETS_REMOTE_URL: "https://remote.caplets.test/caplets", CAPLETS_CONFIG: join(dir, "missing-user-config.json"), CAPLETS_PROJECT_CONFIG: join(dir, "project", ".caplets", "config.json"), }, @@ -2418,7 +2522,7 @@ describe("cli init", () => { await new FileRemoteProfileStore({ root: join(authDir, "remote-profiles"), }).saveSelfHostedProfile({ - hostUrl: "http://127.0.0.1:5387", + hostUrl: "https://remote.caplets.test/caplets", clientId: "rcli_install_test", clientLabel: "Remote Install Test", credentials: { @@ -2432,7 +2536,7 @@ describe("cli init", () => { await runCli(["install", "--global", "--remote", "--json"], { env: { CAPLETS_MODE: "remote", - CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", + CAPLETS_REMOTE_URL: "https://remote.caplets.test/caplets", CAPLETS_CONFIG: join(dir, "missing-user-config.json"), CAPLETS_PROJECT_CONFIG: join(dir, "project", ".caplets", "config.json"), }, @@ -4021,6 +4125,74 @@ describe("cli init", () => { }); }); +function fakeDaemonFirstCliSetup(baseDir?: string) { + const ownsDir = baseDir === undefined; + const dir = baseDir ?? mkdtempSync(join(tmpdir(), "caplets-cli-daemon-first-")); + const daemonBaseUrl = "http://127.0.0.1:5387/caplets"; + const mcpUpserts: unknown[] = []; + return { + daemonBaseUrl, + mcpUpserts, + io: { + env: { ...process.env, CAPLETS_CONFIG: join(dir, "config.json") }, + setupOperations: { + ensureDaemon: async () => ({ + phase: "daemon" as const, + label: "Reuse local Caplets daemon", + status: "reused" as const, + daemonBaseUrl, + }), + }, + mcpOperations: { + detectClients: async () => fakeCliMcpClients(), + listSupportedClients: () => fakeCliMcpClients(), + upsertServer: async (options: { + clientId: string; + daemonBaseUrl: string; + local: boolean; + }) => { + mcpUpserts.push(options); + const client = fakeCliMcpClients().find((entry) => entry.id === options.clientId); + return { + clientId: options.clientId, + success: true, + path: client?.projectConfigPath ?? `/project/${options.clientId}.json`, + }; + }, + }, + }, + cleanup: () => { + if (ownsDir) rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +function fakeCliMcpClients() { + return [ + { + id: "zed", + displayName: "Zed", + configPath: "/home/user/.config/zed/settings.json", + projectConfigPath: "/project/.zed/settings.json", + supportsStdio: true, + }, + { + id: "codex", + displayName: "Codex", + configPath: "/home/user/.codex/config.toml", + projectConfigPath: "/project/.codex/config.toml", + supportsStdio: true, + }, + { + id: "claude-code", + displayName: "Claude Code", + configPath: "/home/user/.claude.json", + projectConfigPath: "/project/.claude.json", + supportsStdio: true, + }, + ]; +} + describe("cli setup", () => { it("prints supported integrations when no integration is provided", async () => { const out: string[] = []; @@ -4034,7 +4206,13 @@ describe("cli setup", () => { expect(text).toContain("opencode"); expect(text).toContain("pi"); expect(text).toContain("mcp-client"); + expect(text).toContain("daemon-first"); + expect(text).toContain("detected MCP clients"); + expect(text).toContain("all supported MCP clients"); + expect(text).toContain("--remote-url"); + expect(text).toContain("--output"); expect(text).toContain("--dry-run"); + expect(text).not.toContain("caplets serve"); expect(text).not.toContain("plugin marketplace"); expect(text).not.toContain("caplets@caplets"); }); @@ -4072,33 +4250,26 @@ describe("cli setup", () => { it("prompts for integrations when stdin is available", async () => { const out: string[] = []; const commands: Array<{ command: string; args: string[] }> = []; + const setup = fakeDaemonFirstCliSetup(); - await runCli(["setup"], { - writeOut: (value) => out.push(value), - readStdin: async () => "1, Claude Code\n", - runSetupCommand: async (command, args) => { - commands.push({ command, args }); - return { stdout: "", stderr: "" }; - }, - }); + try { + await runCli(["setup"], { + ...setup.io, + writeOut: (value) => out.push(value), + readStdin: async () => "1, Claude Code\n", + runSetupCommand: async (command, args) => { + commands.push({ command, args }); + return { stdout: "", stderr: "" }; + }, + }); + } finally { + setup.cleanup(); + } - expect(commands).toEqual([ - { command: "codex", args: ["mcp", "add", "caplets", "--", "caplets", "serve"] }, - { - command: "claude", - args: [ - "mcp", - "add", - "--transport", - "stdio", - "--scope", - "user", - "caplets", - "--", - "caplets", - "serve", - ], - }, + expect(commands).toEqual([]); + expect(setup.mcpUpserts).toEqual([ + { clientId: "codex", daemonBaseUrl: setup.daemonBaseUrl, local: true }, + { clientId: "claude-code", daemonBaseUrl: setup.daemonBaseUrl, local: true }, ]); const text = out.join(""); expect(text).toContain("Select integrations to set up:"); @@ -4106,43 +4277,83 @@ describe("cli setup", () => { expect(text).toContain("Completed Claude Code setup"); }); - it("prompts for a generic MCP client output path during interactive setup", async () => { - const dir = mkdtempSync(join(tmpdir(), "caplets-setup-interactive-")); - const output = join(dir, "caplets.mcp.json"); + it("prompts for a detected MCP client and configures only the selected client", async () => { const out: string[] = []; + const setup = fakeDaemonFirstCliSetup(); + const upserts: unknown[] = []; try { await runCli(["setup"], { + ...setup.io, + mcpOperations: { + detectClients: async () => [fakeCliMcpClients()[0]!], + listSupportedClients: () => fakeCliMcpClients(), + upsertServer: async (options) => { + upserts.push(options); + return { clientId: "zed", success: true, path: "/project/.zed/settings.json" }; + }, + }, writeOut: (value) => out.push(value), - readStdin: async () => `Any MCP client\n${output}\n`, + readStdin: async () => "Any MCP client\nzed\n", }); - expect(JSON.parse(readFileSync(output, "utf8"))).toEqual({ - mcpServers: { caplets: { command: "caplets", args: ["serve"] } }, + expect(upserts).toEqual([ + { clientId: "zed", daemonBaseUrl: setup.daemonBaseUrl, local: true }, + ]); + expect(out.join("")).toContain("Detected MCP clients"); + expect(out.join("")).toContain("Zed"); + expect(out.join("")).not.toContain("codex.toml"); + } finally { + setup.cleanup(); + } + }); + + it("lets interactive MCP setup reveal all supported clients", async () => { + const out: string[] = []; + const setup = fakeDaemonFirstCliSetup(); + + try { + await runCli(["setup"], { + ...setup.io, + mcpOperations: { + ...setup.io.mcpOperations, + detectClients: async () => [fakeCliMcpClients()[0]!], + }, + writeOut: (value) => out.push(value), + readStdin: async () => "Any MCP client\nall\ncodex\n", }); - const text = out.join(""); - expect(text).toContain("Select integrations to set up:"); - expect(text).toContain("Path to write generic MCP config"); - expect(text).toContain(`completed: wrote ${output}`); + + expect(setup.mcpUpserts).toEqual([ + { clientId: "codex", daemonBaseUrl: setup.daemonBaseUrl, local: true }, + ]); + expect(out.join("")).toContain("Supported MCP clients"); + expect(out.join("")).toContain("Codex"); } finally { - rmSync(dir, { recursive: true, force: true }); + setup.cleanup(); } }); it("adds Caplets to Codex MCP config", async () => { const out: string[] = []; const commands: Array<{ command: string; args: string[] }> = []; + const setup = fakeDaemonFirstCliSetup(); - await runCli(["setup", "codex"], { - writeOut: (value) => out.push(value), - runSetupCommand: async (command, args) => { - commands.push({ command, args }); - return { stdout: "", stderr: "" }; - }, - }); + try { + await runCli(["setup", "codex"], { + ...setup.io, + writeOut: (value) => out.push(value), + runSetupCommand: async (command, args) => { + commands.push({ command, args }); + return { stdout: "", stderr: "" }; + }, + }); + } finally { + setup.cleanup(); + } - expect(commands).toEqual([ - { command: "codex", args: ["mcp", "add", "caplets", "--", "caplets", "serve"] }, + expect(commands).toEqual([]); + expect(setup.mcpUpserts).toEqual([ + { clientId: "codex", daemonBaseUrl: setup.daemonBaseUrl, local: true }, ]); expect(out.join("")).toContain("Completed Codex setup"); }); @@ -4150,31 +4361,24 @@ describe("cli setup", () => { it("adds Caplets to Claude Code MCP config", async () => { const out: string[] = []; const commands: Array<{ command: string; args: string[] }> = []; + const setup = fakeDaemonFirstCliSetup(); - await runCli(["setup", "claude-code"], { - writeOut: (value) => out.push(value), - runSetupCommand: async (command, args) => { - commands.push({ command, args }); - return { stdout: "", stderr: "" }; - }, - }); + try { + await runCli(["setup", "claude-code"], { + ...setup.io, + writeOut: (value) => out.push(value), + runSetupCommand: async (command, args) => { + commands.push({ command, args }); + return { stdout: "", stderr: "" }; + }, + }); + } finally { + setup.cleanup(); + } - expect(commands).toEqual([ - { - command: "claude", - args: [ - "mcp", - "add", - "--transport", - "stdio", - "--scope", - "user", - "caplets", - "--", - "caplets", - "serve", - ], - }, + expect(commands).toEqual([]); + expect(setup.mcpUpserts).toEqual([ + { clientId: "claude-code", daemonBaseUrl: setup.daemonBaseUrl, local: true }, ]); expect(out.join("")).toContain("Completed Claude Code setup"); }); @@ -4193,7 +4397,8 @@ describe("cli setup", () => { expect(commands).toEqual([]); expect(out.join("")).toContain("Dry run"); - expect(out.join("")).toContain("codex mcp add caplets -- caplets serve"); + expect(out.join("")).toContain("caplets attach http://127.0.0.1:5387/"); + expect(out.join("")).toContain("planned: configured Codex MCP client"); expect(out.join("")).not.toContain("plugin marketplace"); expect(out.join("")).not.toContain("caplets@caplets"); }); @@ -4203,13 +4408,15 @@ describe("cli setup", () => { const output = join(dir, "nested", "caplets.mcp.json"); const out: string[] = []; + const setup = fakeDaemonFirstCliSetup(dir); try { await runCli(["setup", "mcp-client", "--output", output], { + ...setup.io, writeOut: (value) => out.push(value), }); expect(JSON.parse(readFileSync(output, "utf8"))).toEqual({ - mcpServers: { caplets: { command: "caplets", args: ["serve"] } }, + mcpServers: { caplets: { command: "caplets", args: ["attach", setup.daemonBaseUrl] } }, }); expect(out.join("")).toContain(`completed: wrote ${output}`); } finally { @@ -4221,7 +4428,7 @@ describe("cli setup", () => { await expect(runCli(["setup", "mcp-client"], { writeErr: () => {} })).rejects.toThrow( expect.objectContaining({ code: "REQUEST_INVALID", - message: expect.stringContaining("requires --output "), + message: expect.stringContaining("requires --client or --output "), }) as CapletsError, ); }); @@ -4447,6 +4654,8 @@ describe("cli setup", () => { expect(text, relativePath).not.toMatch(/CAPLETS_REMOTE_(TOKEN|USER|PASSWORD)/u); expect(text, relativePath).not.toMatch(/Basic Auth/u); expect(text, relativePath).not.toMatch(/add-mcp --env/u); + expect(text, relativePath).not.toMatch(/codex mcp add caplets -- caplets serve/u); + expect(text, relativePath).not.toMatch(/claude mcp add[^\n]*caplets serve/u); } }); @@ -4479,20 +4688,34 @@ describe("cli setup", () => { expect(out.join("")).toContain("Completed Codex setup (remote, remote_host)"); }); - it("wraps setup command failures with the failed command", async () => { - await expect( - runCli(["setup", "codex"], { - writeErr: () => {}, - runSetupCommand: async () => { - throw new Error("missing codex binary"); - }, - }), - ).rejects.toThrow( - expect.objectContaining({ - code: "SERVER_UNAVAILABLE", - message: expect.stringContaining("codex mcp add caplets -- caplets serve"), - }) as CapletsError, - ); + it("wraps MCP adapter failures with the selected client", async () => { + const setup = fakeDaemonFirstCliSetup(); + try { + await expect( + runCli(["setup", "codex"], { + ...setup.io, + mcpOperations: { + ...setup.io.mcpOperations, + upsertServer: async () => ({ + clientId: "codex", + success: false, + path: "/project/.codex/config.toml", + error: "missing codex config", + }), + }, + writeErr: () => {}, + }), + ).rejects.toThrow( + expect.objectContaining({ + code: "SERVER_UNAVAILABLE", + message: expect.stringContaining( + "Failed to configure Codex MCP config: missing codex config", + ), + }) as CapletsError, + ); + } finally { + setup.cleanup(); + } }); }); diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 7fb61609..b74d3167 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -187,6 +187,118 @@ describe("config", () => { expect(schema).toContain('"upstreams"'); }); + it("accepts global serve defaults and exposes them in parsed config", () => { + const config = parseConfig({ + serve: { + host: "127.0.0.1", + port: 5480, + path: "/caplets", + remoteStatePath: "/var/lib/caplets/remote-auth", + upstreamUrl: "https://upstream.example.com/caplets", + allowUnauthenticatedHttp: true, + trustProxy: true, + publicOrigins: ["https://caplets.example.com"], + }, + }); + + expect(config).toMatchObject({ + serve: { + host: "127.0.0.1", + port: 5480, + path: "/caplets", + remoteStatePath: "/var/lib/caplets/remote-auth", + upstreamUrl: "https://upstream.example.com/caplets", + allowUnauthenticatedHttp: true, + trustProxy: true, + publicOrigins: ["https://caplets.example.com"], + }, + }); + }); + + it("rejects transport and non-origin values in global serve config", () => { + expect(() => parseConfig({ serve: { transport: "http" } })).toThrow(CapletsError); + expect(() => + parseConfig({ serve: { publicOrigins: ["https://caplets.example.com/path"] } }), + ).toThrow(CapletsError); + expect(() => + parseConfig({ serve: { publicOrigins: ["https://user:pass@caplets.example.com"] } }), + ).toThrow(CapletsError); + }); + + it("publishes origin-only validation for serve public origins", () => { + const schema = configJsonSchema() as { + properties: { + serve: { properties: { publicOrigins: { items: { pattern?: string } } } }; + }; + }; + + expect(schema.properties.serve.properties.publicOrigins.items.pattern).toBeDefined(); + }); + + it("warns and ignores project serve config without dropping project Caplets", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-project-serve-warning-")); + try { + const userRoot = join(dir, "user"); + const projectRoot = join(dir, "project", ".caplets"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(projectRoot, "config.json"); + const projectFilePath = join(projectRoot, "linear.md"); + mkdirSync(userRoot, { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + serve: { port: 5480 }, + mcpServers: { + global: { + name: "Global Server", + description: "A useful global downstream server.", + command: "global-server", + }, + }, + }), + ); + writeFileSync( + projectConfigPath, + JSON.stringify({ + serve: { allowUnauthenticatedHttp: true, port: 9999 }, + }), + ); + writeFileSync( + projectFilePath, + [ + "---", + "name: Linear", + "description: Use Linear from the project Caplet file.", + "mcpServer:", + " command: project-linear", + "---", + "# Linear", + ].join("\n"), + ); + + const { config, sources, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + ); + + expect(config).toMatchObject({ serve: { port: 5480 } }); + expect(config.mcpServers.linear?.command).toBe("project-linear"); + expect(sources.linear).toEqual({ kind: "project-file", path: projectFilePath }); + expect(warnings).toEqual([ + expect.objectContaining({ + kind: "project-config", + path: projectConfigPath, + recoverable: true, + }), + ]); + expect(warnings[0]?.message).toContain("serve"); + expect(warnings[0]?.message).toContain("ignored"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("rejects unsafe or duplicate namespace aliases", () => { expect(() => parseConfig({ diff --git a/packages/core/test/native-options.test.ts b/packages/core/test/native-options.test.ts index 034c54cc..af80f2d8 100644 --- a/packages/core/test/native-options.test.ts +++ b/packages/core/test/native-options.test.ts @@ -1,7 +1,11 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import type { CapletsError } from "../src/errors"; import { resolveNativeCapletsServiceOptions } from "../src/native/options"; +import { readNativeDefaults, writeNativeDefaults } from "../src/native/user-settings"; describe("resolveNativeCapletsServiceOptions", () => { it("defaults to local mode without remote configuration", () => { @@ -23,6 +27,51 @@ describe("resolveNativeCapletsServiceOptions", () => { }); }); + it("uses explicit daemon mode with a credential-free loopback attach URL", () => { + expect( + resolveNativeCapletsServiceOptions( + { mode: "daemon", daemon: { url: "http://127.0.0.1:5387/caplets" } }, + {}, + ), + ).toMatchObject({ + mode: "daemon", + remote: { + url: new URL("http://127.0.0.1:5387/caplets/v1/attach"), + auth: { enabled: false, user: "caplets" }, + }, + }); + }); + + it("uses CAPLETS_DAEMON_URL as daemon mode when no explicit non-daemon mode is set", () => { + expect( + resolveNativeCapletsServiceOptions( + {}, + { CAPLETS_DAEMON_URL: "http://127.0.0.1:5387/caplets" }, + ), + ).toMatchObject({ + mode: "daemon", + remote: { + url: new URL("http://127.0.0.1:5387/caplets/v1/attach"), + auth: { enabled: false, user: "caplets" }, + }, + }); + }); + + it("uses input daemon URL as daemon mode when no explicit non-daemon mode is set", () => { + expect( + resolveNativeCapletsServiceOptions({ daemon: { url: "http://127.0.0.1:5387/caplets" } }, {}), + ).toMatchObject({ mode: "daemon" }); + }); + + it("rejects daemon mode URLs that are not loopback HTTP", () => { + expect(() => + resolveNativeCapletsServiceOptions( + { mode: "daemon", daemon: { url: "http://192.0.2.10:5387/caplets" } }, + {}, + ), + ).toThrow(/loopback/u); + }); + it("uses cloud mode in auto when CAPLETS_REMOTE_URL points at Caplets Cloud", () => { expect( resolveNativeCapletsServiceOptions( @@ -208,3 +257,63 @@ describe("resolveNativeCapletsServiceOptions", () => { ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); }); }); + +describe("native defaults store", () => { + it("writes and reads setup-owned daemon defaults", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-native-defaults-")); + const path = join(dir, "native-defaults.json"); + try { + writeNativeDefaults( + { daemon: { url: "http://127.0.0.1:5387/caplets" }, source: "setup" }, + { path, now: new Date("2026-06-30T00:00:00.000Z") }, + ); + + expect(readNativeDefaults({ path })).toEqual({ + version: 1, + source: "setup", + updatedAt: "2026-06-30T00:00:00.000Z", + daemon: { url: "http://127.0.0.1:5387/caplets" }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("warns and ignores malformed native defaults", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-native-defaults-bad-")); + const path = join(dir, "native-defaults.json"); + const warnings: string[] = []; + try { + writeFileSync(path, "{ not json"); + expect( + readNativeDefaults({ path, writeWarning: (message) => warnings.push(message) }), + ).toBeUndefined(); + expect(warnings.join("\n")).toContain("Ignoring Caplets native defaults"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("warns and ignores native defaults with non-loopback daemon URLs", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-native-defaults-unsafe-")); + const path = join(dir, "native-defaults.json"); + const warnings: string[] = []; + try { + writeFileSync( + path, + JSON.stringify({ + version: 1, + source: "setup", + updatedAt: "2026-06-30T00:00:00.000Z", + daemon: { url: "https://caplets.example.com/caplets" }, + }), + ); + expect( + readNativeDefaults({ path, writeWarning: (message) => warnings.push(message) }), + ).toBeUndefined(); + expect(warnings.join("\n")).toContain("loopback HTTP URL"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/test/native.test.ts b/packages/core/test/native.test.ts index 8bb01bca..f44c9271 100644 --- a/packages/core/test/native.test.ts +++ b/packages/core/test/native.test.ts @@ -134,6 +134,33 @@ describe("native Caplets service", () => { } }); + it("uses daemon mode as a credential-free loopback remote client", async () => { + const remoteOptions: unknown[] = []; + const service = createNativeCapletsService({ + mode: "daemon", + daemon: { url: "http://127.0.0.1:5387/caplets" }, + remoteClientFactory: (options) => { + remoteOptions.push(options); + return { + listTools: async () => [], + callTool: async () => ({ ok: true }), + onToolsChanged: () => () => {}, + close: async () => {}, + }; + }, + }); + + try { + await expect(service.reload()).resolves.toBe(true); + expect(remoteOptions[0]).toMatchObject({ + url: new URL("http://127.0.0.1:5387/caplets/v1/attach"), + auth: { enabled: false, user: "caplets" }, + }); + } finally { + await service.close(); + } + }); + it("suppresses native-first telemetry until a visible notice has been recorded", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ mcpServers: { diff --git a/packages/core/test/package-boundaries.test.ts b/packages/core/test/package-boundaries.test.ts index 41536aea..dfbbf81c 100644 --- a/packages/core/test/package-boundaries.test.ts +++ b/packages/core/test/package-boundaries.test.ts @@ -90,6 +90,10 @@ describe("package boundaries", () => { expect(missingTypeDefinitions).toEqual([]); }); + it("pins add-mcp to the validated programmatic contract", () => { + expect(corePackage.dependencies["add-mcp"]).toBe("1.13.0"); + }); + it("does not publish obsolete Cloud-specific runtime exports", () => { expect(Object.keys(corePackage.exports)).not.toContain("./cloud-runtime"); expect(Object.keys(corePackage.exports)).not.toContain("./cloud/bundle-runtime"); diff --git a/packages/core/test/remote-selection.test.ts b/packages/core/test/remote-selection.test.ts index f7e2dbf9..80f66944 100644 --- a/packages/core/test/remote-selection.test.ts +++ b/packages/core/test/remote-selection.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { CloudAuthStore } from "../src/cloud-auth/store"; +import { installDaemon } from "../src/daemon"; +import type { DaemonConfig, DaemonManager, NativeDaemonStatus } from "../src/daemon/types"; import { FileRemoteProfileStore } from "../src/remote/profile-store"; import { resolveRemoteSelection } from "../src/remote/selection"; import { hostedCredentials, tempCloudAuthPath } from "./fixtures/cloud-auth"; @@ -24,6 +26,104 @@ describe("resolveRemoteSelection", () => { await expect(resolveRemoteSelection({}, {})).rejects.toThrow(/CAPLETS_REMOTE_URL/u); }); + it("resolves setup-validated loopback HTTP attach URLs as credential-free local daemon selections", async () => { + const daemon = await setupPersistedLocalDaemon(); + const fetched: string[] = []; + await expect( + resolveRemoteSelection( + { + remoteUrl: "http://127.0.0.1:5387/caplets", + fetch: async (url) => { + fetched.push(String(url)); + return Response.json({ ok: true }); + }, + }, + daemon.env, + { daemon: daemon.options }, + ), + ).resolves.toMatchObject({ + kind: "local_daemon", + remote: { + baseUrl: new URL("http://127.0.0.1:5387/caplets"), + attachUrl: new URL("http://127.0.0.1:5387/caplets/v1/attach"), + auth: { type: "none" }, + }, + }); + expect(fetched).toEqual(["http://127.0.0.1:5387/caplets/v1/healthz"]); + }); + + it("does not classify spoofed loopback discovery as a local daemon without persisted daemon config", async () => { + const dir = tempDir("caplets-remote-selection-spoof-daemon-"); + const manager = runningDaemonManager(); + const fetched: string[] = []; + await expect( + resolveRemoteSelection( + { + remoteUrl: "http://127.0.0.1:9999/caplets", + fetch: async (url) => { + fetched.push(String(url)); + return Response.json({ name: "caplets", transport: "http" }); + }, + }, + daemonTestEnv(dir), + { daemon: { home: dir, platform: "linux", manager } }, + ), + ).rejects.toMatchObject({ projectBindingCode: "remote_credentials_required" }); + expect(fetched).toEqual([]); + }); + + it("does not classify loopback URLs as local daemons when the persisted daemon URL differs", async () => { + const daemon = await setupPersistedLocalDaemon(); + const fetched: string[] = []; + await expect( + resolveRemoteSelection( + { + remoteUrl: "http://127.0.0.1:9999/caplets", + fetch: async (url) => { + fetched.push(String(url)); + return Response.json({ ok: true }); + }, + }, + daemon.env, + { daemon: daemon.options }, + ), + ).rejects.toMatchObject({ projectBindingCode: "remote_credentials_required" }); + expect(fetched).toEqual([]); + }); + + it("prefers a stored self-hosted Remote Profile over local daemon classification for loopback URLs", async () => { + const authDir = tempDir("caplets-remote-selection-loopback-auth-"); + await new FileRemoteProfileStore({ + root: join(authDir, "remote-profiles"), + }).saveSelfHostedProfile({ + hostUrl: "http://127.0.0.1:5387/caplets", + clientId: "rcli_loopback", + clientLabel: "Loopback Test Device", + credentials: { + accessToken: "profile-access-token", + refreshToken: "profile-refresh-token", + tokenType: "Bearer", + expiresAt: "2999-01-01T00:00:00.000Z", + }, + }); + + await expect( + resolveRemoteSelection( + { authDir }, + { + CAPLETS_MODE: "remote", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/caplets", + }, + ), + ).resolves.toMatchObject({ + kind: "self_hosted_remote", + remote: { + baseUrl: new URL("http://127.0.0.1:5387/caplets"), + auth: { type: "bearer", token: "profile-access-token" }, + }, + }); + }); + it("resolves self-hosted remote auth from a stored Remote Profile", async () => { const authDir = tempDir("caplets-remote-selection-auth-"); await new FileRemoteProfileStore({ @@ -700,3 +800,43 @@ function tempDir(prefix: string): string { tempDirs.push(dir); return dir; } + +async function setupPersistedLocalDaemon(): Promise<{ + env: Record; + options: { home: string; platform: "linux"; manager: DaemonManager }; +}> { + const home = tempDir("caplets-remote-selection-daemon-"); + const env = daemonTestEnv(home); + const manager = runningDaemonManager(); + const options = { home, platform: "linux" as const, manager }; + await installDaemon( + { host: "127.0.0.1", port: 5387, path: "/caplets", validate: false, noRestart: true }, + { env, ...options }, + ); + return { env, options }; +} + +function daemonTestEnv(dir: string): Record { + return { + XDG_CONFIG_HOME: join(dir, "config"), + XDG_STATE_HOME: join(dir, "state"), + }; +} + +function runningDaemonManager(): DaemonManager { + const running: NativeDaemonStatus = { state: "running", installed: true, running: true }; + return { + descriptor: (config: DaemonConfig) => ({ + kind: "systemd-user", + unitName: "caplets-daemon-default.service", + path: config.paths.descriptorFile, + contents: "", + }), + status: async () => running, + install: async () => ({ action: "install", native: running, commands: [] }), + uninstall: async () => ({ action: "uninstall", native: running, commands: [] }), + start: async () => ({ action: "start", native: running, commands: [] }), + restart: async () => ({ action: "restart", native: running, commands: [] }), + stop: async () => ({ action: "stop", native: running, commands: [] }), + }; +} diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index 08475b9b..85dac33b 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -18,6 +18,7 @@ import { createNativeDaemonManager, daemonServeArgs, daemonLogs, + daemonClientBaseUrl, daemonStatus, installDaemon, resolveDaemonHttpServeOptions, @@ -26,6 +27,7 @@ import { startDaemon, stopDaemon, uninstallDaemon, + type DaemonConfig, type DaemonCommandRunner, type DaemonManager, } from "../src/daemon"; @@ -90,6 +92,33 @@ describe("caplets daemon CLI", () => { } }); + it("derives daemon client base URLs from loopback and wildcard HTTP config", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-client-url-")); + try { + const loopback = await installDaemon( + { host: "127.0.0.1", port: 5387, path: "/caplets", validate: false }, + { env: testEnv(dir), platform: "linux", commandRunner: fakeRunner() }, + ); + expect(daemonClientBaseUrl(loopback.config)).toEqual( + new URL("http://127.0.0.1:5387/caplets"), + ); + + const wildcard = { + ...loopback.config, + serve: { ...loopback.config.serve, host: "0.0.0.0" }, + }; + expect(daemonClientBaseUrl(wildcard)).toEqual(new URL("http://127.0.0.1:5387/caplets")); + + const network = { + ...loopback.config, + serve: { ...loopback.config.serve, host: "192.0.2.10" }, + }; + expect(() => daemonClientBaseUrl(network)).toThrow(/loopback/u); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("passes upstream URL through daemon install", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-cli-upstream-")); const upstreamUrl = "https://upstream.caplets.example.com/caplets"; @@ -580,6 +609,106 @@ describe("daemon paths and config", () => { } }); + it("resolves current global serve defaults when restarting defaulted daemons", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-global-restart-")); + try { + const env = testEnv(dir); + const configPath = join(env.XDG_CONFIG_HOME!, "caplets", "config.json"); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify({ serve: { port: 5480 } })); + const installOptions = { + env, + home: "/home/alice", + platform: "linux" as const, + commandRunner: fakeRunner({ active: true }), + }; + + const installed = await installDaemon({ validate: false }, installOptions); + expect(installed.config.serve.port).toBe(5480); + + writeFileSync(configPath, JSON.stringify({ serve: { port: 5481 } })); + const restarted: DaemonConfig[] = []; + const manager = captureRestartManager(restarted); + + await restartDaemon({ + ...installOptions, + manager, + fetch: async () => new Response("ok"), + }); + + expect(restarted).toHaveLength(1); + expect(restarted[0]?.serve.port).toBe(5481); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps explicit daemon serve settings ahead of changed global defaults", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-explicit-restart-")); + try { + const env = testEnv(dir); + const configPath = join(env.XDG_CONFIG_HOME!, "caplets", "config.json"); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify({ serve: { port: 5480 } })); + const installOptions = { + env, + home: "/home/alice", + platform: "linux" as const, + commandRunner: fakeRunner({ active: true }), + }; + + const installed = await installDaemon({ validate: false, port: "6000" }, installOptions); + expect(installed.config.serve.port).toBe(6000); + + writeFileSync(configPath, JSON.stringify({ serve: { port: 5481 } })); + const restarted: DaemonConfig[] = []; + const manager = captureRestartManager(restarted); + + await restartDaemon({ + ...installOptions, + manager, + fetch: async () => new Response("ok"), + }); + + expect(restarted).toHaveLength(1); + expect(restarted[0]?.serve.port).toBe(6000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("lets setup-style explicit loopback unauthenticated daemon options override unsafe globals", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-safe-setup-globals-")); + try { + const env = testEnv(dir); + const configPath = join(env.XDG_CONFIG_HOME!, "caplets", "config.json"); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync( + configPath, + JSON.stringify({ + serve: { host: "0.0.0.0", port: 5480, allowUnauthenticatedHttp: false }, + }), + ); + + const installed = await installDaemon( + { validate: false, host: "127.0.0.1", allowUnauthenticatedHttp: true }, + { + env, + home: "/home/alice", + platform: "linux", + commandRunner: fakeRunner({ active: true }), + }, + ); + + expect(installed.config.serve.host).toBe("127.0.0.1"); + expect(installed.config.serve.port).toBe(5480); + expect(installed.config.serve.allowUnauthenticatedHttp).toBe(true); + expect(installed.config.serve.auth.type).toBe("development_unauthenticated"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("emits remote credential state and no Basic Auth flags for default daemon serve", () => { const serve = resolveDaemonHttpServeOptions({}); @@ -1683,6 +1812,32 @@ describe("daemon lifecycle and logs", () => { } }); + it("stops native services when persisted daemon config is missing", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-stale-config-stop-")); + try { + const runner = fakeRunner({ active: true }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + }; + const installed = await installDaemon({ validate: false }, options); + rmSync(installed.config.paths.configFile, { force: true }); + runner.commands.length = 0; + + await stopDaemon(options); + + expect(runner.commands).toContainEqual([ + "systemctl", + "--user", + "stop", + "caplets-daemon-default.service", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("start restarts when the installed daemon is already running", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-restart-")); try { @@ -2816,3 +2971,27 @@ function fakeRunner( }, }; } + +function captureRestartManager(restarted: DaemonConfig[]): DaemonManager { + const native = { state: "running" as const, installed: true, running: true }; + return { + descriptor: (config) => ({ + kind: "systemd-user", + unitName: "caplets-daemon-default.service", + path: config.paths.descriptorFile, + contents: "", + }), + status: async () => native, + install: async () => ({ action: "install", native }), + uninstall: async () => ({ action: "uninstall", native }), + start: async (config) => { + restarted.push(config); + return { action: "start", native }; + }, + restart: async (config) => { + restarted.push(config); + return { action: "restart", native }; + }, + stop: async () => ({ action: "stop", native }), + }; +} diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index 0ea5cc54..2405ff0a 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -2209,6 +2209,26 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("allows attach requests through additional configured public origin hosts", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp( + httpOptions({ + publicOrigin: "https://primary.example.com", + publicOrigins: ["https://primary.example.com", "https://secondary.example.com"], + allowUnauthenticatedHttp: true, + }), + engine, + { writeErr: () => {} }, + ); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { host: "secondary.example.com" }, + }); + + expect(response.status).toBe(200); + await engine.close(); + }); + it("allows authenticated attach requests through the configured public origin host", async () => { const { engine } = testEngine(); const store = remoteCredentialStore(); @@ -2245,6 +2265,31 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("allows authenticated attach requests through additional configured public origin hosts", async () => { + const { engine } = testEngine(); + const store = remoteCredentialStore(); + const credentials = pairedClient(store, "https://secondary.example.com/"); + const app = createHttpServeApp( + httpOptions({ + publicOrigin: "https://primary.example.com", + publicOrigins: ["https://primary.example.com", "https://secondary.example.com"], + auth: { type: "remote_credentials" }, + }), + engine, + { writeErr: () => {}, remoteCredentialStore: store }, + ); + + const response = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { + host: "secondary.example.com", + authorization: `Bearer ${credentials.accessToken}`, + }, + }); + + expect(response.status).toBe(200); + await engine.close(); + }); + it("rejects unauthenticated MCP requests through public origin host by default", async () => { const { engine } = testEngine(); const app = createHttpServeApp( diff --git a/packages/core/test/serve-options.test.ts b/packages/core/test/serve-options.test.ts index 2febf7a9..a81dcad9 100644 --- a/packages/core/test/serve-options.test.ts +++ b/packages/core/test/serve-options.test.ts @@ -37,6 +37,116 @@ describe("resolveServeOptions", () => { }); }); + it("uses global serve config defaults for HTTP serving", () => { + const resolved = resolveServeOptions( + { transport: "http" }, + {}, + { + host: "0.0.0.0", + port: 5480, + path: "/caplets", + remoteStatePath: "/configured/remote-auth", + upstreamUrl: "https://upstream.example.com/caplets", + allowUnauthenticatedHttp: true, + trustProxy: true, + publicOrigins: ["https://caplets.example.com"], + }, + ); + expect(resolved).toMatchObject({ + transport: "http", + host: "0.0.0.0", + port: 5480, + path: "/caplets", + auth: { type: "development_unauthenticated" }, + allowUnauthenticatedHttp: true, + upstreamUrl: "https://upstream.example.com/caplets", + trustProxy: true, + publicOrigin: "https://caplets.example.com", + }); + expect("remoteCredentialStateDir" in resolved).toBe(false); + }); + + it("keeps the first public origin canonical while preserving additional origins", () => { + expect( + resolveServeOptions( + { transport: "http" }, + {}, + { + publicOrigins: ["https://primary.example.com", "https://secondary.example.com"], + }, + ), + ).toMatchObject({ + transport: "http", + publicOrigin: "https://primary.example.com", + publicOrigins: ["https://primary.example.com", "https://secondary.example.com"], + }); + }); + + it("keeps global HTTP serve defaults from affecting stdio", () => { + expect(resolveServeOptions({ transport: "stdio" }, {}, { port: 5480 })).toEqual({ + transport: "stdio", + }); + }); + + it("applies CLI and environment values before global serve config defaults", () => { + expect( + resolveServeOptions( + { transport: "http", port: 6000 }, + { CAPLETS_SERVER_URL: "http://localhost:7000/env" }, + { host: "0.0.0.0", port: 5480, path: "/configured" }, + ), + ).toMatchObject({ + transport: "http", + host: "localhost", + port: 6000, + path: "/env", + publicOrigin: "http://localhost:7000", + }); + }); + + it("keeps configured secondary public origins when CAPLETS_SERVER_URL is set", () => { + expect( + resolveServeOptions( + { transport: "http" }, + { CAPLETS_SERVER_URL: "https://primary.example.com/caplets" }, + { + publicOrigins: ["https://primary.example.com", "https://secondary.example.com"], + }, + ), + ).toMatchObject({ + publicOrigin: "https://primary.example.com", + publicOrigins: ["https://primary.example.com", "https://secondary.example.com"], + }); + }); + + it("lets explicit false command booleans override true global serve defaults", () => { + expect( + resolveServeOptions( + { transport: "http", allowUnauthenticatedHttp: false, trustProxy: false }, + {}, + { allowUnauthenticatedHttp: true, trustProxy: true }, + ), + ).toMatchObject({ + transport: "http", + auth: { type: "remote_credentials" }, + allowUnauthenticatedHttp: false, + trustProxy: false, + }); + }); + + it("preserves legacy credential-free daemon auth before applying global defaults", () => { + expect( + resolveDaemonHttpServeOptions( + { preserveUnauthenticatedAuth: true }, + {}, + { allowUnauthenticatedHttp: false }, + ), + ).toMatchObject({ + auth: { type: "development_unauthenticated" }, + allowUnauthenticatedHttp: true, + }); + }); + it("preserves HTTPS CAPLETS_SERVER_URL as the public origin", () => { expect( resolveServeOptions( diff --git a/packages/core/test/setup-runner.test.ts b/packages/core/test/setup-runner.test.ts index fb92bb6d..8d22b9ef 100644 --- a/packages/core/test/setup-runner.test.ts +++ b/packages/core/test/setup-runner.test.ts @@ -1,15 +1,19 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { CapletConfig } from "../src/config"; -import { runSetup } from "../src/cli/setup"; +import { runInteractiveSetup, runSetup, type SetupMcpUpsertOptions } from "../src/cli/setup"; import { capletSetupContentHash } from "../src/setup/hash"; import { LocalSetupStore } from "../src/setup/local-store"; import { runCapletSetup, type SetupSpawn } from "../src/setup/runner"; import type { SetupAttempt, SetupTargetKind } from "../src/setup/types"; describe("setup runner", () => { + afterEach(() => { + vi.doUnmock("../src/daemon"); + }); + it("accepts only local_host, remote_host, and hosted_sandbox setup targets", async () => { const accepted: SetupTargetKind[] = ["local_host", "remote_host", "hosted_sandbox"]; expect([...accepted].sort()).toEqual(["hosted_sandbox", "local_host", "remote_host"]); @@ -226,6 +230,574 @@ describe("setup runner", () => { } }); + it("creates user config before daemon setup and reports daemon-backed JSON phases", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-first-setup-")); + const configPath = join(dir, "config.json"); + const upserts: unknown[] = []; + try { + const result = JSON.parse( + await runSetup("codex", { + format: "json", + env: { CAPLETS_CONFIG: configPath }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async (options) => { + upserts.push(options); + return { clientId: "codex", success: true, path: join(dir, "codex.toml") }; + }, + }, + setupOperations: { + ensureDaemon: async () => { + expect(existsSync(configPath)).toBe(true); + return { + phase: "daemon", + label: "Start local Caplets daemon", + status: "completed", + daemonBaseUrl: "http://127.0.0.1:5387/caplets", + message: "daemon is healthy", + }; + }, + }, + }), + ); + + expect(result.phases).toMatchObject([ + { phase: "config", status: "completed", path: configPath }, + { phase: "daemon", status: "completed", daemonBaseUrl: "http://127.0.0.1:5387/caplets" }, + { phase: "integration", status: "completed" }, + ]); + expect(upserts).toEqual([ + { clientId: "codex", daemonBaseUrl: "http://127.0.0.1:5387/caplets", local: true }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("installs default local setup daemons as credential-free loopback services", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-local-daemon-auth-")); + const daemon = mockDaemonModule(); + daemon.daemonStatus.mockResolvedValueOnce({ installed: false, running: false }); + daemon.installDaemon.mockResolvedValueOnce( + daemonInstallResult({ + allowUnauthenticatedHttp: true, + auth: { type: "development_unauthenticated" }, + }), + ); + vi.resetModules(); + vi.doMock("../src/daemon", () => daemon); + const { runSetup: mockedRunSetup } = await import("../src/cli/setup"); + try { + await mockedRunSetup("mcp-client", { + client: "zed", + env: { CAPLETS_CONFIG: join(dir, "config.json") }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => ({ + clientId: "zed", + success: true, + path: join(dir, "zed.json"), + }), + }, + }); + + expect(daemon.installDaemon).toHaveBeenCalledWith( + expect.objectContaining({ + start: true, + host: "127.0.0.1", + allowUnauthenticatedHttp: true, + }), + expect.anything(), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("ignores unsafe global serve defaults when preparing local setup daemons", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-local-daemon-global-defaults-")); + const configPath = join(dir, "config.json"); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + noop: { + name: "Noop", + description: "A harmless placeholder MCP server.", + command: "node", + }, + }, + serve: { host: "0.0.0.0", allowUnauthenticatedHttp: false }, + }), + ); + const daemon = mockDaemonModule(); + daemon.daemonStatus.mockResolvedValueOnce({ installed: false, running: false }); + daemon.installDaemon.mockResolvedValueOnce( + daemonInstallResult({ + allowUnauthenticatedHttp: true, + auth: { type: "development_unauthenticated" }, + }), + ); + const upserts: SetupMcpUpsertOptions[] = []; + vi.resetModules(); + vi.doMock("../src/daemon", () => daemon); + const { runSetup: mockedRunSetup } = await import("../src/cli/setup"); + try { + await mockedRunSetup("mcp-client", { + client: "zed", + env: { CAPLETS_CONFIG: configPath }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async (options) => { + upserts.push(options); + return { clientId: "zed", success: true, path: join(dir, "zed.json") }; + }, + }, + }); + + expect(daemon.installDaemon).toHaveBeenCalledWith( + expect.objectContaining({ + start: true, + host: "127.0.0.1", + allowUnauthenticatedHttp: true, + }), + expect.anything(), + ); + expect(upserts).toEqual([ + { clientId: "zed", daemonBaseUrl: "http://127.0.0.1:5387/caplets", local: true }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("updates healthy loopback daemons before local setup when they still require remote credentials", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-local-daemon-repair-auth-")); + const daemon = mockDaemonModule(); + daemon.daemonStatus.mockResolvedValueOnce({ + installed: true, + running: true, + health: { ok: true }, + config: daemonConfig({ + allowUnauthenticatedHttp: false, + auth: { type: "remote_credentials" }, + }), + }); + daemon.installDaemon.mockResolvedValueOnce( + daemonInstallResult({ + allowUnauthenticatedHttp: true, + auth: { type: "development_unauthenticated" }, + }), + ); + vi.resetModules(); + vi.doMock("../src/daemon", () => daemon); + const { runSetup: mockedRunSetup } = await import("../src/cli/setup"); + try { + const result = JSON.parse( + await mockedRunSetup("mcp-client", { + client: "zed", + format: "json", + env: { CAPLETS_CONFIG: join(dir, "config.json") }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => ({ + clientId: "zed", + success: true, + path: join(dir, "zed.json"), + }), + }, + }), + ); + + expect(daemon.installDaemon).toHaveBeenCalledWith( + expect.objectContaining({ + start: true, + host: "127.0.0.1", + allowUnauthenticatedHttp: true, + }), + expect.anything(), + ); + expect(result.phases).toMatchObject([ + { phase: "config", status: "completed" }, + { phase: "daemon", status: "completed", daemonBaseUrl: "http://127.0.0.1:5387/caplets" }, + { phase: "integration", status: "completed" }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects existing non-loopback daemons before making local setup credential-free", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-local-daemon-network-auth-")); + const daemon = mockDaemonModule(); + daemon.daemonStatus.mockResolvedValueOnce({ + installed: true, + running: true, + health: { ok: true }, + config: daemonConfig({ + host: "0.0.0.0", + allowUnauthenticatedHttp: false, + auth: { type: "remote_credentials" }, + }), + }); + vi.resetModules(); + vi.doMock("../src/daemon", () => daemon); + const { runSetup: mockedRunSetup } = await import("../src/cli/setup"); + try { + await expect( + mockedRunSetup("mcp-client", { + client: "zed", + env: { CAPLETS_CONFIG: join(dir, "config.json") }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => ({ + clientId: "zed", + success: true, + path: join(dir, "zed.json"), + }), + }, + }), + ).rejects.toMatchObject({ + code: "REQUEST_INVALID", + message: expect.stringContaining("cannot configure credential-free local attach"), + }); + expect(daemon.installDaemon).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails before daemon or integration work when an existing user config is invalid", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-first-invalid-config-")); + const configPath = join(dir, "config.json"); + const commands: Array<{ command: string; args: string[] }> = []; + let daemonCalled = false; + let upsertCalled = false; + try { + writeFileSync(configPath, "{ not json"); + + await expect( + runSetup("codex", { + env: { CAPLETS_CONFIG: configPath }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => { + upsertCalled = true; + return { clientId: "codex", success: true, path: join(dir, "codex.toml") }; + }, + }, + runCommand: async (command, args) => { + commands.push({ command, args }); + return { stdout: "", stderr: "" }; + }, + setupOperations: { + ensureDaemon: async () => { + daemonCalled = true; + return { + phase: "daemon", + label: "Start local Caplets daemon", + status: "completed", + daemonBaseUrl: "http://127.0.0.1:5387/caplets", + }; + }, + }, + }), + ).rejects.toMatchObject({ code: "CONFIG_INVALID" }); + expect(daemonCalled).toBe(false); + expect(upsertCalled).toBe(false); + expect(commands).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("validates the working tree project config before daemon or integration work", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-first-project-config-")); + const configPath = join(dir, "user-config.json"); + const projectDir = join(dir, "project"); + const projectConfigPath = join(projectDir, ".caplets", "config.json"); + const previousCwd = process.cwd(); + let daemonCalled = false; + let upsertCalled = false; + try { + writeFileSync(configPath, "{}\n"); + mkdirSync(dirname(projectConfigPath), { recursive: true }); + writeFileSync(projectConfigPath, "{ not json"); + process.chdir(projectDir); + + await expect( + runSetup("codex", { + env: { CAPLETS_CONFIG: configPath }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => { + upsertCalled = true; + return { clientId: "codex", success: true, path: join(dir, "codex.toml") }; + }, + }, + setupOperations: { + ensureDaemon: async () => { + daemonCalled = true; + return { + phase: "daemon", + label: "Start local Caplets daemon", + status: "completed", + daemonBaseUrl: "http://127.0.0.1:5387/caplets", + }; + }, + }, + }), + ).rejects.toMatchObject({ code: "CONFIG_INVALID" }); + + expect(daemonCalled).toBe(false); + expect(upsertCalled).toBe(false); + } finally { + process.chdir(previousCwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("validates the working tree project config during first-run setup before daemon work", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-first-new-user-project-config-")); + const configPath = join(dir, "user-config.json"); + const projectDir = join(dir, "project"); + const projectConfigPath = join(projectDir, ".caplets", "config.json"); + const previousCwd = process.cwd(); + let daemonCalled = false; + let upsertCalled = false; + try { + mkdirSync(dirname(projectConfigPath), { recursive: true }); + writeFileSync(projectConfigPath, "{ not json"); + process.chdir(projectDir); + + await expect( + runSetup("codex", { + env: { CAPLETS_CONFIG: configPath }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => { + upsertCalled = true; + return { clientId: "codex", success: true, path: join(dir, "codex.toml") }; + }, + }, + setupOperations: { + ensureDaemon: async () => { + daemonCalled = true; + return { + phase: "daemon", + label: "Start local Caplets daemon", + status: "completed", + daemonBaseUrl: "http://127.0.0.1:5387/caplets", + }; + }, + }, + }), + ).rejects.toMatchObject({ code: "CONFIG_INVALID" }); + + expect(daemonCalled).toBe(false); + expect(upsertCalled).toBe(false); + } finally { + process.chdir(previousCwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects invalid integration options before config or daemon phases", async () => { + let configCalled = false; + let daemonCalled = false; + + await expect( + runSetup("mcp-client", { + setupOperations: { + ensureUserConfig: () => { + configCalled = true; + return { + phase: "config", + label: "Initialize user Caplets config", + status: "completed", + }; + }, + ensureDaemon: () => { + daemonCalled = true; + return { + phase: "daemon", + label: "Start local Caplets daemon", + status: "completed", + daemonBaseUrl: "http://127.0.0.1:5387/caplets", + }; + }, + }, + }), + ).rejects.toMatchObject({ code: "REQUEST_INVALID" }); + + expect(configCalled).toBe(false); + expect(daemonCalled).toBe(false); + }); + + it("reports daemon failure and does not call integration writers", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-first-daemon-failure-")); + const configPath = join(dir, "config.json"); + const commands: Array<{ command: string; args: string[] }> = []; + let upsertCalled = false; + try { + await expect( + runSetup("codex", { + env: { CAPLETS_CONFIG: configPath }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => { + upsertCalled = true; + return { clientId: "codex", success: true, path: join(dir, "codex.toml") }; + }, + }, + runCommand: async (command, args) => { + commands.push({ command, args }); + return { stdout: "", stderr: "" }; + }, + setupOperations: { + ensureDaemon: async () => { + throw new Error("daemon health probe failed"); + }, + }, + }), + ).rejects.toMatchObject({ + code: "SERVER_UNAVAILABLE", + message: expect.stringContaining("daemon health probe failed"), + }); + expect(commands).toEqual([]); + expect(upsertCalled).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("dry-runs local setup phases without writes, daemon operations, or integration mutation", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-first-dry-run-")); + const configPath = join(dir, "config.json"); + const commands: Array<{ command: string; args: string[] }> = []; + let daemonCalled = false; + let upsertCalled = false; + try { + const result = JSON.parse( + await runSetup("codex", { + dryRun: true, + format: "json", + env: { CAPLETS_CONFIG: configPath }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => { + upsertCalled = true; + return { clientId: "codex", success: true, path: join(dir, "codex.toml") }; + }, + }, + runCommand: async (command, args) => { + commands.push({ command, args }); + return { stdout: "", stderr: "" }; + }, + setupOperations: { + ensureDaemon: async () => { + daemonCalled = true; + return { + phase: "daemon", + label: "Start local Caplets daemon", + status: "completed", + daemonBaseUrl: "http://127.0.0.1:5387/caplets", + }; + }, + }, + }), + ); + + expect(existsSync(configPath)).toBe(false); + expect(daemonCalled).toBe(false); + expect(upsertCalled).toBe(false); + expect(commands).toEqual([]); + expect(result.phases).toMatchObject([ + { phase: "config", status: "planned", path: configPath }, + { phase: "daemon", status: "planned" }, + { phase: "integration", status: "planned" }, + ]); + expect(result.actions[0]).toMatchObject({ status: "planned" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("native setup installs the plugin and writes daemon defaults", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-native-setup-defaults-")); + const daemonBaseUrl = "http://127.0.0.1:5387/caplets"; + const commands: Array<{ command: string; args: string[] }> = []; + const defaultsPath = join(dir, "native-defaults.json"); + try { + const result = JSON.parse( + await runSetup("opencode", { + format: "json", + env: { CAPLETS_CONFIG: join(dir, "config.json") }, + nativeDefaultsPath: defaultsPath, + runCommand: async (command, args) => { + commands.push({ command, args }); + return { stdout: "", stderr: "" }; + }, + setupOperations: fakeSetupPhases(daemonBaseUrl), + }), + ); + + expect(commands).toEqual([ + { command: "opencode", args: ["plugin", "@caplets/opencode", "--global"] }, + ]); + expect(JSON.parse(readFileSync(defaultsPath, "utf8"))).toMatchObject({ + version: 1, + source: "setup", + daemon: { url: daemonBaseUrl }, + }); + expect(result.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ status: "completed", path: defaultsPath }), + ]), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reports healthy existing daemon reuse before integration", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-first-reuse-")); + const configPath = join(dir, "config.json"); + try { + const result = JSON.parse( + await runSetup("codex", { + format: "json", + env: { CAPLETS_CONFIG: configPath }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => ({ + clientId: "codex", + success: true, + path: join(dir, "codex.toml"), + }), + }, + setupOperations: { + ensureDaemon: async () => ({ + phase: "daemon", + label: "Reuse local Caplets daemon", + status: "reused", + daemonBaseUrl: "http://127.0.0.1:5387/caplets", + }), + }, + }), + ); + + expect(result.phases).toMatchObject([ + { phase: "config", status: "completed" }, + { phase: "daemon", status: "reused", daemonBaseUrl: "http://127.0.0.1:5387/caplets" }, + { phase: "integration", status: "completed" }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("uses neutral setup target names in CLI setup copy", async () => { const local = await runSetup("opencode", { target: "local_host", @@ -264,6 +836,214 @@ describe("setup runner", () => { }, ); + it("configures a targeted add-mcp client with daemon attach and no credential env", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-mcp-target-")); + const daemonBaseUrl = "http://127.0.0.1:5387/caplets"; + const upserts: unknown[] = []; + try { + const result = JSON.parse( + await runSetup("mcp-client", { + client: "zed", + format: "json", + env: { + CAPLETS_CONFIG: join(dir, "config.json"), + CAPLETS_REMOTE_TOKEN: "must-not-leak", + VAULT_PASSWORD: "must-not-leak", + }, + setupOperations: fakeSetupPhases(daemonBaseUrl), + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async (options) => { + upserts.push(options); + return { clientId: "zed", success: true, path: join(dir, "zed.json") }; + }, + }, + }), + ); + + expect(upserts).toEqual([{ clientId: "zed", daemonBaseUrl, local: true }]); + expect(JSON.stringify(upserts)).not.toContain("must-not-leak"); + expect(result.actions).toMatchObject([ + { + status: "completed", + clientId: "zed", + command: "caplets attach http://127.0.0.1:5387/caplets", + path: join(dir, "zed.json"), + }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("filters detected MCP clients to stdio-capable choices before prompting", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-mcp-detected-stdio-")); + const daemonBaseUrl = "http://127.0.0.1:5387/caplets"; + const clients = [ + { + id: "vscode", + displayName: "VS Code", + configPath: join(dir, "vscode.json"), + supportsStdio: false, + }, + { + id: "zed", + displayName: "Zed", + configPath: join(dir, "zed.json"), + supportsStdio: true, + }, + ]; + const prompts: string[] = []; + const upserts: unknown[] = []; + try { + await runInteractiveSetup({ + env: { CAPLETS_CONFIG: join(dir, "config.json") }, + setupOperations: fakeSetupPhases(daemonBaseUrl), + mcpOperations: { + listSupportedClients: () => clients, + detectClients: () => clients, + upsertServer: async (options) => { + upserts.push(options); + return { clientId: "zed", success: true, path: join(dir, "zed.json") }; + }, + }, + readPrompt: async (prompt) => { + prompts.push(prompt); + return prompts.length === 1 ? "mcp-client" : ""; + }, + }); + + expect(prompts[1]).toContain("Zed (zed)"); + expect(prompts[1]).not.toContain("VS Code (vscode)"); + expect(upserts).toEqual([{ clientId: "zed", daemonBaseUrl, local: true }]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps codex as a compatibility alias for the add-mcp adapter", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-mcp-codex-")); + const daemonBaseUrl = "http://127.0.0.1:5387/caplets"; + const upserts: unknown[] = []; + const commands: unknown[] = []; + try { + await runSetup("codex", { + env: { CAPLETS_CONFIG: join(dir, "config.json") }, + runCommand: async (command, args) => { + commands.push({ command, args }); + return { stdout: "", stderr: "" }; + }, + setupOperations: fakeSetupPhases(daemonBaseUrl), + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async (options) => { + upserts.push(options); + return { clientId: "codex", success: true, path: join(dir, "codex.toml") }; + }, + }, + }); + + expect(commands).toEqual([]); + expect(upserts).toEqual([{ clientId: "codex", daemonBaseUrl, local: true }]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("dry-runs targeted add-mcp client setup without mutating adapter state", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-mcp-dry-run-")); + let upsertCalled = false; + try { + const result = JSON.parse( + await runSetup("mcp-client", { + client: "zed", + dryRun: true, + format: "json", + env: { CAPLETS_CONFIG: join(dir, "config.json") }, + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => { + upsertCalled = true; + return { clientId: "zed", success: true, path: join(dir, "zed.json") }; + }, + }, + }), + ); + + expect(upsertCalled).toBe(false); + expect(result.actions).toMatchObject([ + { + status: "planned", + clientId: "zed", + path: "/project/.zed/settings.json", + scope: "project", + }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("surfaces selected MCP client, scope, path, and adapter warnings in plain output", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-mcp-plain-warning-")); + try { + const output = await runSetup("mcp-client", { + client: "zed", + env: { CAPLETS_CONFIG: join(dir, "config.json") }, + setupOperations: fakeSetupPhases("http://127.0.0.1:5387/caplets"), + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => ({ + clientId: "zed", + success: true, + path: join(dir, "zed.json"), + droppedFields: ["headers"], + extraPaths: [join(dir, "backup.json")], + }), + }, + }); + + expect(output).toContain("configured Zed MCP client (project)"); + expect(output).toContain(`at ${join(dir, "zed.json")}`); + expect(output).toContain("command: caplets attach http://127.0.0.1:5387/caplets"); + expect(output).toContain("dropped unsupported fields: headers"); + expect(output).toContain(`additional paths: ${join(dir, "backup.json")}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("surfaces add-mcp dropped fields and extra paths in JSON output", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-mcp-warning-")); + try { + const result = JSON.parse( + await runSetup("mcp-client", { + client: "zed", + format: "json", + env: { CAPLETS_CONFIG: join(dir, "config.json") }, + setupOperations: fakeSetupPhases("http://127.0.0.1:5387/caplets"), + mcpOperations: { + listSupportedClients: () => fakeMcpClients(), + upsertServer: async () => ({ + clientId: "zed", + success: true, + path: join(dir, "zed.json"), + droppedFields: ["headers"], + extraPaths: [join(dir, "backup.json")], + }), + }, + }), + ); + + expect(result.actions[0]).toMatchObject({ + droppedFields: ["headers"], + extraPaths: [join(dir, "backup.json")], + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("records setup hash and runtime features without requiring project output retention", async () => { const store = memoryStore(); const attempts = await runCapletSetup({ @@ -315,6 +1095,89 @@ describe("setup runner", () => { }); }); +function fakeSetupPhases(daemonBaseUrl: string) { + return { + ensureDaemon: async () => ({ + phase: "daemon" as const, + label: "Reuse local Caplets daemon", + status: "reused" as const, + daemonBaseUrl, + }), + }; +} + +function fakeMcpClients() { + return [ + { + id: "codex", + displayName: "Codex", + configPath: "/home/user/.codex/config.toml", + projectConfigPath: "/project/.codex/config.toml", + supportsStdio: true, + }, + { + id: "claude-code", + displayName: "Claude Code", + configPath: "/home/user/.claude.json", + projectConfigPath: "/project/.claude.json", + supportsStdio: true, + }, + { + id: "zed", + displayName: "Zed", + configPath: "/home/user/.config/zed/settings.json", + projectConfigPath: "/project/.zed/settings.json", + supportsStdio: true, + }, + ]; +} + +function mockDaemonModule() { + return { + daemonStatus: vi.fn(), + installDaemon: vi.fn(), + daemonClientBaseUrl: vi.fn( + (config: { serve: { host: string; port: number; path: string } }) => + new URL(`http://${config.serve.host}:${config.serve.port}${config.serve.path}`), + ), + }; +} + +function daemonInstallResult(serve: { + host?: string; + allowUnauthenticatedHttp: boolean; + auth: { type: "development_unauthenticated" | "remote_credentials" }; +}) { + const config = daemonConfig(serve); + return { + status: { running: true, health: { ok: true }, config }, + config, + validation: { ok: true }, + plannedActions: ["install", "start"], + }; +} + +function daemonConfig(serve: { + host?: string; + allowUnauthenticatedHttp: boolean; + auth: { type: "development_unauthenticated" | "remote_credentials" }; +}) { + return { + version: 1, + id: "default", + serve: { + transport: "http", + host: "127.0.0.1", + port: 5387, + path: "/caplets", + loopback: true, + warnUnauthenticatedNetwork: false, + trustProxy: false, + ...serve, + }, + }; +} + function caplet(command: string, args: string[]): CapletConfig { return { server: "ast-grep", diff --git a/packages/opencode/README.md b/packages/opencode/README.md index b5de498b..d8b33302 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -22,12 +22,13 @@ rebuilt from current Caplets state for the tools registered when the plugin load current plugin API snapshots `Hooks.tool` at plugin load, so adding, removing, or renaming native tools still requires restarting OpenCode; newly added tools are not advertised until restart. -## Remote Selection +## Runtime Selection -By default the plugin reads local Caplets config. Use `CAPLETS_MODE` and `CAPLETS_REMOTE_*` to select local, self-hosted remote, or Caplets Cloud behavior: +`caplets setup opencode` installs the plugin and writes non-secret daemon defaults so the plugin connects to the local Caplets daemon by default. Use `CAPLETS_MODE`, `CAPLETS_DAEMON_URL`, and `CAPLETS_REMOTE_*` to select local in-process, daemon, self-hosted remote, or Caplets Cloud behavior: ```sh CAPLETS_MODE=local opencode +CAPLETS_MODE=daemon CAPLETS_DAEMON_URL=http://127.0.0.1:5387/ opencode CAPLETS_MODE=remote CAPLETS_REMOTE_URL=https://caplets.example.com/caplets opencode CAPLETS_MODE=cloud CAPLETS_REMOTE_URL=https://cloud.caplets.dev opencode ``` @@ -60,7 +61,7 @@ export default { }; ``` -Plugin config overrides environment variables. The explicit config shape is `{ mode, remote: { url, pollIntervalMs } }`; credentials come from `caplets remote login `. +Plugin config overrides environment variables and setup-written daemon defaults. The explicit config shape is `{ mode, daemon: { url, pollIntervalMs }, remote: { url, pollIntervalMs } }`; daemon mode is credential-free loopback, and remote credentials come from `caplets remote login `. ## Anonymous Telemetry diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5b2a112a..ff160369 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,12 +1,14 @@ import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import { createNativeCapletsService, + hasNativeRuntimeSelectionEnv, + readNativeDefaults, registerNativeCapletsProcessCleanup, type NativeCapletsServiceOptions, } from "@caplets/core/native"; import { createCapletsOpenCodeHooks } from "./hooks"; -export type CapletsOpenCodeConfig = Pick; +export type CapletsOpenCodeConfig = Pick; const plugin = (async (_ctx: PluginInput, config?: CapletsOpenCodeConfig) => { const service = createNativeCapletsService({ @@ -21,13 +23,17 @@ const plugin = (async (_ctx: PluginInput, config?: CapletsOpenCodeConfig) => { }) as Plugin; function normalizeOpenCodeConfig(config: CapletsOpenCodeConfig | undefined): CapletsOpenCodeConfig { - if (!config) { - return {}; - } - return { - ...(config.mode ? { mode: config.mode } : {}), - ...(config.remote ? { remote: config.remote } : {}), - }; + const explicitConfig = config + ? { + ...(config.mode ? { mode: config.mode } : {}), + ...(config.remote ? { remote: config.remote } : {}), + ...(config.daemon ? { daemon: config.daemon } : {}), + } + : undefined; + if (explicitConfig && Object.keys(explicitConfig).length > 0) return explicitConfig; + if (hasNativeRuntimeSelectionEnv()) return {}; + const defaults = readNativeDefaults(); + return defaults ? { mode: "daemon", daemon: { url: defaults.daemon.url } } : {}; } export default plugin; diff --git a/packages/opencode/test/opencode.test.ts b/packages/opencode/test/opencode.test.ts index 5a159ce8..ded2bff3 100644 --- a/packages/opencode/test/opencode.test.ts +++ b/packages/opencode/test/opencode.test.ts @@ -320,6 +320,8 @@ describe("@caplets/opencode", () => { close: vi.fn(async () => {}), })), registerNativeCapletsProcessCleanup: vi.fn(), + hasNativeRuntimeSelectionEnv: vi.fn(() => false), + readNativeDefaults: vi.fn(() => undefined), }; vi.doMock("@caplets/core/native", () => nativeMocks); const plugin = (await import("../src/index")).default; @@ -356,6 +358,8 @@ describe("@caplets/opencode", () => { close: vi.fn(async () => {}), })), registerNativeCapletsProcessCleanup: vi.fn(), + hasNativeRuntimeSelectionEnv: vi.fn(() => false), + readNativeDefaults: vi.fn(() => undefined), }; vi.doMock("@caplets/core/native", () => nativeMocks); const plugin = (await import("../src/index")).default; @@ -372,6 +376,98 @@ describe("@caplets/opencode", () => { }); }); + it("uses Caplets native defaults when no explicit config is provided", async () => { + vi.resetModules(); + const nativeMocks = { + createNativeCapletsService: vi.fn(() => ({ + listTools: () => [], + execute: vi.fn(async () => ({})), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => {}), + close: vi.fn(async () => {}), + })), + registerNativeCapletsProcessCleanup: vi.fn(), + hasNativeRuntimeSelectionEnv: vi.fn(() => false), + readNativeDefaults: vi.fn(() => ({ + version: 1, + source: "setup", + updatedAt: "2026-06-30T00:00:00.000Z", + daemon: { url: "http://127.0.0.1:5387/caplets" }, + })), + }; + vi.doMock("@caplets/core/native", () => nativeMocks); + const plugin = (await import("../src/index")).default; + + await plugin({} as never, undefined as never); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({ + mode: "daemon", + daemon: { url: "http://127.0.0.1:5387/caplets" }, + telemetryIntegration: "opencode", + }); + }); + + it("uses Caplets native defaults when OpenCode passes an empty config object", async () => { + vi.resetModules(); + const nativeMocks = { + createNativeCapletsService: vi.fn(() => ({ + listTools: () => [], + execute: vi.fn(async () => ({})), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => {}), + close: vi.fn(async () => {}), + })), + registerNativeCapletsProcessCleanup: vi.fn(), + hasNativeRuntimeSelectionEnv: vi.fn(() => false), + readNativeDefaults: vi.fn(() => ({ + version: 1, + source: "setup", + updatedAt: "2026-06-30T00:00:00.000Z", + daemon: { url: "http://127.0.0.1:5387/caplets" }, + })), + }; + vi.doMock("@caplets/core/native", () => nativeMocks); + const plugin = (await import("../src/index")).default; + + await plugin({} as never, {} as never); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({ + mode: "daemon", + daemon: { url: "http://127.0.0.1:5387/caplets" }, + telemetryIntegration: "opencode", + }); + }); + + it("lets native environment selectors override Caplets native defaults", async () => { + vi.resetModules(); + const nativeMocks = { + createNativeCapletsService: vi.fn(() => ({ + listTools: () => [], + execute: vi.fn(async () => ({})), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => {}), + close: vi.fn(async () => {}), + })), + registerNativeCapletsProcessCleanup: vi.fn(), + hasNativeRuntimeSelectionEnv: vi.fn(() => true), + readNativeDefaults: vi.fn(() => ({ + version: 1, + source: "setup", + updatedAt: "2026-06-30T00:00:00.000Z", + daemon: { url: "http://127.0.0.1:5387/caplets" }, + })), + }; + vi.doMock("@caplets/core/native", () => nativeMocks); + const plugin = (await import("../src/index")).default; + + await plugin({} as never, undefined as never); + + expect(nativeMocks.readNativeDefaults).not.toHaveBeenCalled(); + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({ + telemetryIntegration: "opencode", + }); + }); + it("awaits initial native service reload before creating hooks", async () => { vi.resetModules(); const tools = [ @@ -397,6 +493,8 @@ describe("@caplets/opencode", () => { const nativeMocks = { createNativeCapletsService: vi.fn(() => service), registerNativeCapletsProcessCleanup: vi.fn(), + hasNativeRuntimeSelectionEnv: vi.fn(() => false), + readNativeDefaults: vi.fn(() => undefined), }; vi.doMock("@caplets/core/native", () => nativeMocks); const plugin = (await import("../src/index")).default; diff --git a/packages/pi/README.md b/packages/pi/README.md index 9085bf5b..046c10f8 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -35,12 +35,13 @@ running without `getActiveTools()` / `setActiveTools()`, stale tools may remain Pi reloads extensions or restarts, but calls to removed Caplets return Caplets' normal structured "server not found" error. -## Remote Selection +## Runtime Selection -By default the extension uses the local Caplets native service. Use `CAPLETS_MODE` and `CAPLETS_REMOTE_*` to select local, self-hosted remote, or Caplets Cloud behavior: +`caplets setup pi` installs the extension and writes non-secret daemon defaults so the extension connects to the local Caplets daemon by default. Use `CAPLETS_MODE`, `CAPLETS_DAEMON_URL`, and `CAPLETS_REMOTE_*` to select local in-process, daemon, self-hosted remote, or Caplets Cloud behavior: ```sh CAPLETS_MODE=local pi +CAPLETS_MODE=daemon CAPLETS_DAEMON_URL=http://127.0.0.1:5387/ pi CAPLETS_MODE=remote CAPLETS_REMOTE_URL=https://caplets.example.com/caplets pi CAPLETS_MODE=cloud CAPLETS_REMOTE_URL=https://cloud.caplets.dev pi ``` @@ -89,8 +90,7 @@ export default createCapletsPiExtension({ }); ``` -The explicit config shape is `{ mode, remote: { url, pollIntervalMs } }`. Credentials come from -`caplets remote login `, not settings files or source code. +Explicit args override Pi settings, and Pi settings override setup-written daemon defaults. The explicit config shape is `{ mode, daemon: { url, pollIntervalMs }, remote: { url, pollIntervalMs } }`. Daemon mode is credential-free loopback. Remote credentials come from `caplets remote login `, not settings files or source code. ## Anonymous Telemetry diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index 1c624779..05c266a1 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -11,13 +11,15 @@ import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { generatedToolInputJsonSchema } from "@caplets/core/generated-tool-input-schema"; import { createNativeCapletsService, + hasNativeRuntimeSelectionEnv, + readNativeDefaults, registerNativeCapletsProcessCleanup, type NativeCapletTool, type NativeCapletsService, type NativeCapletsServiceOptions, } from "@caplets/core/native"; -type PiNativeCapletsOptions = Pick; +type PiNativeCapletsOptions = Pick; export type PiExtensionApi = Pick & Partial> & { @@ -109,7 +111,15 @@ async function registerCapletsPiExtension( ownsService && !explicitNativeOptions && options.loadSettings ? await loadPiSettingsArgs(options) : undefined; - const serviceOptions = explicitNativeOptions ?? settingsArgs ?? {}; + const defaultsArgs = + ownsService && + !explicitNativeOptions && + !hasNativeRuntimeSettings(settingsArgs) && + !hasNativeRuntimeSelectionEnv() + ? nativeDefaultsServiceOptions(options.writeWarning) + : undefined; + const serviceOptions = + explicitNativeOptions ?? mergeNativeServiceOptions(defaultsArgs, settingsArgs); const service = options.service ?? createNativeCapletsService({ @@ -272,7 +282,13 @@ function parsePiNativeOptions( const result: PiCapletsSettings = {}; const mode = raw.mode; if (mode !== undefined) { - if (mode !== "auto" && mode !== "local" && mode !== "remote" && mode !== "cloud") { + if ( + mode !== "auto" && + mode !== "local" && + mode !== "remote" && + mode !== "cloud" && + mode !== "daemon" + ) { return undefined; } result.mode = mode; @@ -302,17 +318,41 @@ function parsePiNativeOptions( } const pollIntervalMs = remote.pollIntervalMs; if (pollIntervalMs !== undefined) { - if (typeof pollIntervalMs !== "number" || !Number.isFinite(pollIntervalMs)) return undefined; + if (!isValidNativePollIntervalMs(pollIntervalMs)) return undefined; parsedRemote.pollIntervalMs = pollIntervalMs; } result.remote = parsedRemote; } + const daemon = objectProperty(value, "daemon"); + if (raw.daemon !== undefined && !daemon) { + return undefined; + } + if (daemon) { + const parsedDaemon: NonNullable = {}; + const url = daemon.url; + if (url !== undefined) { + if (typeof url !== "string") return undefined; + parsedDaemon.url = url; + } + const pollIntervalMs = daemon.pollIntervalMs; + if (pollIntervalMs !== undefined) { + if (!isValidNativePollIntervalMs(pollIntervalMs)) return undefined; + parsedDaemon.pollIntervalMs = pollIntervalMs; + } + result.daemon = parsedDaemon; + } if (raw.server !== undefined) { return undefined; } return result; } +function isValidNativePollIntervalMs(value: unknown): value is number { + return ( + typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 1_000 + ); +} + function capletsRemoteStatusText(status: "connected" | "offline", nerdFontIcons: boolean): string { if (nerdFontIcons) { return status === "connected" ? "󰖟 caplets ✓" : "󰖟 caplets ×"; @@ -324,6 +364,30 @@ function nativeServiceOptions(options: PiCapletsSettings): PiNativeCapletsOption return { ...(options.mode ? { mode: options.mode } : {}), ...(options.remote ? { remote: options.remote } : {}), + ...(options.daemon ? { daemon: options.daemon } : {}), + }; +} + +function hasNativeRuntimeSettings(options: PiCapletsSettings | undefined): boolean { + return Boolean(options?.mode || options?.remote || options?.daemon); +} + +function nativeDefaultsServiceOptions( + writeWarning: ((message: string) => void) | undefined, +): PiNativeCapletsOptions { + const defaults = readNativeDefaults({ + writeWarning: (message) => writeWarning?.(`[caplets/pi] ${message}`), + }); + return defaults ? { mode: "daemon", daemon: { url: defaults.daemon.url } } : {}; +} + +function mergeNativeServiceOptions( + defaults: PiNativeCapletsOptions | undefined, + settings: PiCapletsSettings | undefined, +): PiNativeCapletsOptions { + return { + ...defaults, + ...nativeServiceOptions(settings ?? {}), }; } @@ -340,8 +404,11 @@ function shouldShowStatusWidget( return ( options.mode === "remote" || options.mode === "cloud" || + options.mode === "daemon" || !!options.remote?.url || - process.env.CAPLETS_REMOTE_URL !== undefined + process.env.CAPLETS_REMOTE_URL !== undefined || + process.env.CAPLETS_MODE === "daemon" || + process.env.CAPLETS_DAEMON_URL !== undefined ); } diff --git a/packages/pi/test/pi.test.ts b/packages/pi/test/pi.test.ts index e96e536e..61eed2f2 100644 --- a/packages/pi/test/pi.test.ts +++ b/packages/pi/test/pi.test.ts @@ -15,6 +15,8 @@ import capletsPiExtension, { const nativeMocks = vi.hoisted(() => ({ createNativeCapletsService: vi.fn(), registerNativeCapletsProcessCleanup: vi.fn(), + hasNativeRuntimeSelectionEnv: vi.fn<() => boolean>(() => false), + readNativeDefaults: vi.fn<() => unknown>(() => undefined), })); const fsMocks = vi.hoisted(() => ({ @@ -85,6 +87,10 @@ type MockService = NativeCapletsService & { describe("@caplets/pi", () => { beforeEach(() => { vi.clearAllMocks(); + nativeMocks.hasNativeRuntimeSelectionEnv.mockReset(); + nativeMocks.hasNativeRuntimeSelectionEnv.mockReturnValue(false); + nativeMocks.readNativeDefaults.mockReset(); + nativeMocks.readNativeDefaults.mockReturnValue(undefined); fsMocks.readFile.mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })); }); it("uses the core generated schema as Pi tool parameters", () => { @@ -1108,6 +1114,30 @@ describe("@caplets/pi", () => { ); }); + it("rejects daemon poll intervals that core will reject", async () => { + const writeWarning = vi.fn(); + fsMocks.readFile.mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { + mode: "daemon", + daemon: { + url: "http://127.0.0.1:5387/caplets", + pollIntervalMs: 999, + }, + }, + }), + ); + fsMocks.readFile.mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })); + + const args = await loadPiSettingsArgs({ writeWarning }); + + expect(args).toEqual({}); + expect(writeWarning).toHaveBeenCalledWith( + expect.stringContaining("Ignoring Pi settings args: invalid"), + ); + }); + it("rejects malformed legacy remote and server settings in Pi config", async () => { const writeWarning = vi.fn(); fsMocks.readFile.mockResolvedValueOnce( @@ -1215,6 +1245,52 @@ describe("@caplets/pi", () => { }); }); + it("default export uses Caplets native defaults when Pi settings are missing", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + nativeMocks.readNativeDefaults.mockReturnValueOnce({ + version: 1, + source: "setup", + updatedAt: "2026-06-30T00:00:00.000Z", + daemon: { url: "http://127.0.0.1:5387/caplets" }, + }); + fsMocks.readFile + .mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })) + .mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })); + const { api } = mockPiApi(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenLastCalledWith({ + mode: "daemon", + daemon: { url: "http://127.0.0.1:5387/caplets" }, + telemetryIntegration: "pi", + }); + }); + + it("lets native environment selectors override Caplets native defaults when Pi settings are missing", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + nativeMocks.hasNativeRuntimeSelectionEnv.mockReturnValueOnce(true); + nativeMocks.readNativeDefaults.mockReturnValueOnce({ + version: 1, + source: "setup", + updatedAt: "2026-06-30T00:00:00.000Z", + daemon: { url: "http://127.0.0.1:5387/caplets" }, + }); + fsMocks.readFile + .mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })) + .mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })); + const { api } = mockPiApi(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + + expect(nativeMocks.readNativeDefaults).not.toHaveBeenCalled(); + expect(nativeMocks.createNativeCapletsService).toHaveBeenLastCalledWith({ + telemetryIntegration: "pi", + }); + }); + it("default export falls back to empty args when Pi settings are missing", async () => { const service = mockService([]); nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); @@ -1284,6 +1360,31 @@ describe("@caplets/pi", () => { expect(renderStatusWidget(setWidget)).toBe("󰖟 caplets ✓"); }); + it("shows the status widget when daemon mode is selected through env", async () => { + const previousDaemonUrl = process.env.CAPLETS_DAEMON_URL; + process.env.CAPLETS_DAEMON_URL = "http://127.0.0.1:5387/caplets"; + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + nativeMocks.hasNativeRuntimeSelectionEnv.mockReturnValueOnce(true); + fsMocks.readFile.mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })); + const { api } = mockPiApi(); + const setWidget = vi.fn(); + try { + await capletsPiExtension(api as unknown as PiExtensionApi); + triggerSessionStart(api, { ui: { setWidget } }); + + expect(setWidget).toHaveBeenCalledWith("caplets", expect.any(Function), { + placement: "belowEditor", + }); + } finally { + if (previousDaemonUrl === undefined) { + delete process.env.CAPLETS_DAEMON_URL; + } else { + process.env.CAPLETS_DAEMON_URL = previousDaemonUrl; + } + } + }); + it("can disable nerd font icons in the remote status widget", async () => { const service = mockService([]); nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e23395d9..dcc70729 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,6 +319,9 @@ importers: '@ungap/structured-clone': specifier: ^1.3.2 version: 1.3.2 + add-mcp: + specifier: 1.13.0 + version: 1.13.0 ajv: specifier: ^8.20.0 version: 8.20.0 @@ -948,10 +951,16 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@clack/core@0.4.1': + resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} + '@clack/core@1.4.2': resolution: {integrity: sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==} engines: {node: '>= 20.12.0'} + '@clack/prompts@0.9.1': + resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==} + '@clack/prompts@1.6.0': resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} engines: {node: '>= 20.12.0'} @@ -3123,6 +3132,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + add-mcp@1.13.0: + resolution: {integrity: sha512-ESxIzbzvJM0XPaSd8Os4WbDVGYB5rSg3V9mKzvCzjKff6E9DN86Ncr+csq4kQ8G+Ws7v7EGiZpy3tpNww2lyjQ==} + engines: {node: '>=18'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3462,6 +3476,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -7207,11 +7225,22 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@clack/core@0.4.1': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@clack/core@1.4.2': dependencies: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@clack/prompts@0.9.1': + dependencies: + '@clack/core': 0.4.1 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@clack/prompts@1.6.0': dependencies: '@clack/core': 1.4.2 @@ -9006,6 +9035,15 @@ snapshots: acorn@8.17.0: {} + add-mcp@1.13.0: + dependencies: + '@clack/prompts': 0.9.1 + '@iarna/toml': 2.2.5 + chalk: 5.6.2 + commander: 13.1.0 + js-yaml: 4.3.0 + jsonc-parser: 3.3.1 + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -9317,7 +9355,7 @@ snapshots: buffer-image-size@0.6.4: dependencies: - '@types/node': 26.0.1 + '@types/node': 22.20.0 bundle-name@4.1.0: dependencies: @@ -9401,6 +9439,8 @@ snapshots: commander@11.1.0: {} + commander@13.1.0: {} + commander@14.0.3: {} commander@15.0.0: {} @@ -10071,7 +10111,7 @@ snapshots: happy-dom@20.10.6: dependencies: - '@types/node': 26.0.1 + '@types/node': 22.20.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 buffer-image-size: 0.6.4 diff --git a/schemas/caplets-config.schema.json b/schemas/caplets-config.schema.json index 46cbc33b..cad9d03a 100644 --- a/schemas/caplets-config.schema.json +++ b/schemas/caplets-config.schema.json @@ -33,6 +33,54 @@ "description": "Set false to disable anonymous Caplets telemetry for this user config.", "type": "boolean" }, + "serve": { + "description": "User-owned HTTP serve defaults. Ignored from project config for security.", + "type": "object", + "properties": { + "host": { + "description": "Default HTTP bind host for caplets serve.", + "type": "string", + "minLength": 1 + }, + "port": { + "description": "Default HTTP port.", + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "path": { + "description": "Default HTTP base path.", + "type": "string" + }, + "remoteStatePath": { + "description": "Default remote credential state directory for HTTP serve.", + "type": "string", + "minLength": 1 + }, + "upstreamUrl": { + "description": "Default upstream Caplets URL for stacked HTTP serve.", + "type": "string" + }, + "allowUnauthenticatedHttp": { + "description": "Opt in to unauthenticated HTTP serving; intended only for trusted local use.", + "type": "boolean" + }, + "trustProxy": { + "description": "Trust proxy headers when deriving public HTTP request URLs.", + "type": "boolean" + }, + "publicOrigins": { + "description": "Additional public HTTP origins.", + "type": "array", + "items": { + "type": "string", + "pattern": "^https?:\\/\\/(?![^/?#]*@)[^/?#]+\\/?$", + "description": "Public HTTP(S) origin for DNS rebinding and credential audience checks." + } + } + }, + "additionalProperties": false + }, "completion": { "default": { "discoveryTimeoutMs": 750, diff --git a/scripts/check-public-docs.ts b/scripts/check-public-docs.ts index 7bc23829..2e3479d5 100644 --- a/scripts/check-public-docs.ts +++ b/scripts/check-public-docs.ts @@ -38,7 +38,10 @@ const requiredContent = new Map([ '"command": "npx"', ], ], - ["configuration.mdx", ["https://caplets.dev/config.schema.json", ".caplets/config.json"]], + [ + "configuration.mdx", + ["https://caplets.dev/config.schema.json", ".caplets/config.json", "serve", "publicOrigins"], + ], [ "code-mode.mdx", ["caplets__code_mode", "caplets.osv.searchTools", "caplets.osv.callTool", "sessionId"], @@ -49,7 +52,13 @@ const requiredContent = new Map([ ["troubleshooting.mdx", ["caplets doctor", "CAPLETS_CONFIG"]], [ "reference/config.mdx", - ["https://caplets.dev/config.schema.json", "Required", "googleDiscoveryApis"], + [ + "https://caplets.dev/config.schema.json", + "Required", + "googleDiscoveryApis", + "serve", + "publicOrigins", + ], ], ["reference/code-mode-api.mdx", ["CapletHandle", "DebugApi", "CapletsResult"]], [ @@ -58,7 +67,7 @@ const requiredContent = new Map([ ], ]); -const forbiddenPatterns = [ +const forbiddenInternalPatterns = [ "docs/adr", "docs/product", "docs/plans", @@ -73,6 +82,8 @@ const forbiddenPatterns = [ "github.com/spiritledsoftware/caplets/blob/main/docs/specs", ]; +const forbiddenGuidancePatterns = ["serve.allowedHosts", '"allowedHosts"', "serve.transport"]; + const failures: string[] = []; for (const page of requiredPages) { @@ -97,11 +108,18 @@ for (const page of requiredPages) { for (const path of [join(docsRoot, "astro.config.mjs"), ...walkFiles(docsSourceRoot)]) { const text = readFileSync(path, "utf8"); - for (const pattern of forbiddenPatterns) { + for (const pattern of forbiddenInternalPatterns) { if (text.includes(pattern)) { failures.push(`${relative(repoRoot, path)} references internal docs path ${pattern}.`); } } + for (const pattern of forbiddenGuidancePatterns) { + if (text.includes(pattern)) { + failures.push( + `${relative(repoRoot, path)} contains forbidden public docs guidance ${pattern}.`, + ); + } + } } if (failures.length > 0) { diff --git a/scripts/generate-docs-reference.ts b/scripts/generate-docs-reference.ts index 27a40d4c..5f08ced5 100644 --- a/scripts/generate-docs-reference.ts +++ b/scripts/generate-docs-reference.ts @@ -124,6 +124,7 @@ function schemaPage({ const majorSections = rows.filter(({ name }) => [ + "serve", "completion", "options", "mcpServers", @@ -255,6 +256,20 @@ function commonSchemaRecipes(sourcePath: string): string { "}", "```", "", + "Global HTTP serve defaults (user config only):", + "", + "```json", + "{", + ' "$schema": "https://caplets.dev/config.schema.json",', + ' "serve": {', + ' "host": "127.0.0.1",', + ' "port": 5387,', + ' "publicOrigins": ["https://caplets.example.com"]', + " },", + ' "mcpServers": {}', + "}", + "```", + "", "Keep `options.exposure` at the default `code_mode` unless your client cannot run Code", "Mode. Add backend maps such as `mcpServers`, `openapiEndpoints`,", "`googleDiscoveryApis`, `graphqlEndpoints`, `httpApis`, `cliTools`, or `capletSets` only",