diff --git a/.changeset/caplets-vault.md b/.changeset/caplets-vault.md new file mode 100644 index 00000000..2f51352f --- /dev/null +++ b/.changeset/caplets-vault.md @@ -0,0 +1,6 @@ +--- +"@caplets/core": minor +"caplets": minor +--- + +Add Caplets Vault for encrypted runtime-owned string values, `$vault:` config interpolation, access grants, CLI management, and GitHub catalog Vault setup. diff --git a/.env.example b/.env.example index 0246cf9d..5e990910 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ ALCHEMY_PASSWORD= ALCHEMY_STATE_TOKEN= -CLOUDFLARE_API_TOKEN= CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_API_TOKEN= CLOUDFLARE_EMAIL= diff --git a/CONCEPTS.md b/CONCEPTS.md index 869b4be7..ba201c16 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -14,6 +14,24 @@ 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. +### Caplets Vault + +A runtime-owned encrypted string store whose values can be referenced from Caplets config with `$vault:NAME` or `${vault:NAME}`. + +Caplets Vault replaces fragile agent-harness environment propagation for secret-like config values. Each runtime owns its own Vault store; local Caplets do not read, mirror, or forward remote or Cloud Vault values. + +### Raw Vault Reveal + +The explicit human-facing action that prints a Vault value in cleartext. + +Raw Vault Reveal is separate from config interpolation and agent-facing runtime execution. Generic remote-control and agent surfaces must not treat caller-provided request fields as proof that a reveal is authorized. + +### Vault Access Grant + +The authorization record that lets a specific Caplet reference resolve a specific Vault key during runtime config interpolation. + +Vault Access Grants are identified by Caplet ID, reference name, and config origin. The stored Vault key is mutable grant data so remapping a reference replaces the target key instead of leaving stale grants behind. + ### Install-Time Service Contract The persisted daemon agreement that defines what command runs, which environment model applies, which native service identity owns it, and how updates become active. diff --git a/CONTEXT.md b/CONTEXT.md index 0bc75923..4fdb0d13 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -31,3 +31,7 @@ _Avoid_: Discovery backend, discovery document backend, OpenAPI-backed Google AP **Media artifact**: A file-backed Caplets result for response content that should not be returned inline, such as binary media or oversized textual content. _Avoid_: Inline blob, base64 result, download blob + +**Caplets Vault**: +A runtime-owned encrypted string store whose values can be referenced from Caplets config with `$vault:NAME` or `${vault:NAME}`. +_Avoid_: Caplets Secrets, project secrets, shared encrypted project vault diff --git a/README.md b/README.md index 67a4525f..c33cd51d 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,28 @@ caplets remote login https://cloud.caplets.dev CAPLETS_MODE=cloud CAPLETS_REMOTE_URL=https://cloud.caplets.dev opencode ``` +## Caplets Vault + +Caplets Vault stores secret-like string values in the runtime that uses them, encrypted at rest for +local/global Caplets and owned by the selected remote runtime for `--remote` operations. Reference +Vault values in config with `$vault:NAME` or `${vault:NAME}`. + +```sh +caplets vault set GH_TOKEN --grant github +caplets vault get GH_TOKEN +caplets vault get GH_TOKEN --show +``` + +Use `--remote` when the Caplet runs in a self-hosted remote or hosted Cloud runtime: + +```sh +caplets vault set GH_TOKEN --remote --grant github +caplets vault access grant GH_TOKEN github --remote +``` + +Vault values are not exposed through Code Mode, progressive tools, or native agent APIs. Unset or +ungranted Vault references quarantine only the affected Caplet and appear in `caplets doctor`. + ## Benchmark The deterministic benchmark compares flat MCP exposure with Caplets over the same mock @@ -173,7 +195,7 @@ tokens than direct vanilla MCP. Live runs are model- and environment-dependent; deterministic benchmark is the reproducible claim. See [docs/benchmarks/coding-agent.md](https://github.com/spiritledsoftware/caplets/blob/main/docs/benchmarks/coding-agent.md) for methodology and reproduction commands. -See [docs.caplets.dev/changelog](https://docs.caplets.dev/changelog/) for public release notes. +See [GitHub Releases](https://github.com/spiritledsoftware/caplets/releases) for public release notes. ## Repository @@ -209,6 +231,7 @@ Package map: Long-lived docs: - [Code Mode PRD](https://github.com/spiritledsoftware/caplets/blob/main/docs/product/caplets-code-mode-prd.md) +- [Caplets Vault](https://github.com/spiritledsoftware/caplets/blob/main/docs/product/caplets-vault.md) - [Architecture](https://github.com/spiritledsoftware/caplets/blob/main/docs/architecture.md) - [ADR 0001: Code Mode default exposure](https://github.com/spiritledsoftware/caplets/blob/main/docs/adr/0001-code-mode-default-exposure.md) - [Benchmark methodology](https://github.com/spiritledsoftware/caplets/blob/main/docs/benchmarks/coding-agent.md) diff --git a/apps/docs/README.md b/apps/docs/README.md index 0882b8fd..44d5d9a2 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -5,12 +5,11 @@ Astro Starlight documentation site for [docs.caplets.dev](https://docs.caplets.d Public docs live in `apps/docs/src/content/docs`. Root `docs/` is internal maintainer documentation and is not routed into this site. -Generated reference pages and the public changelog come from: +Generated reference pages come from: - `schemas/caplets-config.schema.json` - `schemas/caplet.schema.json` - `packages/core/src/code-mode/runtime-api.d.ts` -- `CHANGELOG.md` ## Commands diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 7260aa98..eda7b857 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -32,7 +32,7 @@ export default defineConfig({ { label: "Get Started", items: [ - { label: "Start here", link: "/" }, + { label: "Quick Start", link: "/" }, { label: "Install", link: "/install/" }, { label: "Configuration", link: "/configuration/" }, ], @@ -42,6 +42,7 @@ export default defineConfig({ items: [ { label: "Code Mode", link: "/code-mode/" }, { label: "Add capabilities", link: "/capabilities/" }, + { label: "Caplets Vault", link: "/vault/" }, { label: "Agent integrations", link: "/agent-integrations/" }, { label: "Remote attach", link: "/remote-attach/" }, { label: "Troubleshooting", link: "/troubleshooting/" }, @@ -53,7 +54,10 @@ export default defineConfig({ { label: "Configuration schema", link: "/reference/config/" }, { label: "Code Mode API", link: "/reference/code-mode-api/" }, { label: "Caplet files", link: "/reference/caplet-files/" }, - { label: "Changelog", link: "/changelog/" }, + { + label: "GitHub releases", + link: "https://github.com/spiritledsoftware/caplets/releases", + }, ], }, ], diff --git a/apps/docs/src/content/docs/changelog.mdx b/apps/docs/src/content/docs/changelog.mdx deleted file mode 100644 index f33fb136..00000000 --- a/apps/docs/src/content/docs/changelog.mdx +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: "Changelog" -description: "Public release notes for Caplets." ---- - -{/* */} - -GitHub releases: [github.com/spiritledsoftware/caplets/releases](https://github.com/spiritledsoftware/caplets/releases) - -## Unreleased - -### Major Changes - -- Breaking: Caplet progressive wrapper operation names now use `check`, `tools`, `describe_tool`, resource/prompt operation names without `list_`, and `name`/`args` fields instead of `tool`/`prompt`/`arguments`. Code Mode declarations now expose comprehensive Caplet handles with paginated discovery, result envelopes, resource/prompt methods, loose TypeScript diagnostics, and schema-derived `callSignature`. - -## 0.12.0 - -### Minor Changes - -- 0168746: Add project-first Caplet authoring with `caplets add`, make `caplets install` write to `./.caplets` by default, and load project Caplets without an explicit trust gate. - - Project Caplets now override global Caplets with source and shadowing information surfaced through `caplets list`. Use `-g` or `--global` with `caplets add` and `caplets install` to write to the user Caplets root. - -## 0.11.0 - -### Minor Changes - -- aa0c0b3: Add `cliTools` Caplet backends for typed, shell-free CLI actions plus `caplets author cli` for generating reviewable CLI Caplet manifests. - -## 0.10.0 - -### Minor Changes - -- 957e528: Add reproducible coding-agent benchmarks comparing direct MCP exposure with Caplets, including deterministic report checks and opt-in live Pi/OpenCode harnesses. -- 957e528: Add schema-aware `call_tool.fields` projection for MCP, OpenAPI, and HTTP-backed tools that expose output schemas. - -## 0.9.0 - -### Minor Changes - -- 5657a01: Support configured OAuth/OIDC client metadata URLs for MCP, OpenAPI, GraphQL, and HTTP auth configs. -- 5657a01: Default user config and OAuth token state locations now follow XDG conventions on Unix-like platforms and Windows platform conventions on Windows. - -## 0.8.0 - -### Minor Changes - -- 349459a: Add native HTTP actions for explicitly configured non-OpenAPI APIs. - -## 0.7.0 - -### Minor Changes - -- 359eba4: # Hot reload serve config - - Add default hot reload for `caplets serve`, including live config and Caplet file reconciliation without restarting the MCP process. - -### Patch Changes - -- 85bfe0c: Use the MCP SDK OAuth auth provider for remote OAuth MCP transports instead of precomputing static bearer headers, allowing SDK-managed refresh, resource metadata, and auth challenge handling. - -## 0.6.0 - -### Minor Changes - -- ad63f47: # CLI inspection and Caplet installation - - Add CLI inspection commands for version, configured Caplets, resolved config paths, and installing Caplets from a repo. - -## 0.5.2 - -### Patch Changes - -- 99bce4a: Fix MCP OAuth/OIDC token exchange for dynamically registered clients. - -## 0.5.1 - -### Patch Changes - -- bcd0dde: Fix MCP OAuth/OIDC login for configured public clients by including the client ID in the token exchange. - -## 0.5.0 - -### Minor Changes - -- 6e5ec50: Add native GraphQL Caplets with configured or auto-generated operations, OAuth/OIDC discovery for OpenAPI and GraphQL backends, and safer credential handling for discovered auth flows. - -## 0.4.0 - -### Minor Changes - -- 9c9f3e2: Add native OpenAPI-backed Caplets alongside MCP server backends. - - OpenAPI endpoint configs can now expose one generated Caplet tool per API spec, progressively disclose operations as tools, and execute HTTP calls through the existing `call_tool` flow. The implementation includes explicit OpenAPI auth configuration, safe spec loading, guarded request construction, generated schema updates, and documentation for `openapiEndpoints`. - -## 0.3.0 - -### Minor Changes - -- b924a7b: Add MCP-backed Markdown Caplet files with Caplet-first discovery operations. - -## 0.2.1 - -### Patch Changes - -- f936020: Load project config from `./.caplets/config.json` alongside user config, with project values taking precedence while preserving user-only servers. Fix OAuth login token exchange for clients with secret authentication, and clarify generated Caplets tool descriptions so downstream tool inputs are passed under `call_tool.arguments`. - -## 0.2.0 - -### Minor Changes - -- 0d4c5df: Add the Caplets configuration quickstart, generated JSON Schema support, top-level config options, and Commander-based CLI commands for init and OAuth auth management. - -## 0.1.0 - -### Minor Changes - -- 34da37a: Set up release automation with Changesets, Husky hooks, and GitHub Actions CI/release workflows. diff --git a/apps/docs/src/content/docs/configuration.mdx b/apps/docs/src/content/docs/configuration.mdx index 06bac193..a537f7ab 100644 --- a/apps/docs/src/content/docs/configuration.mdx +++ b/apps/docs/src/content/docs/configuration.mdx @@ -63,3 +63,28 @@ After changing config, run: ```sh caplets doctor ``` + +## Secret values + +Use [Caplets Vault](/vault/) for tokens, API keys, private URLs, and other values that +should not live in agent config or depend on agent environment propagation. + +```json +{ + "mcpServers": { + "github": { + "command": "github-mcp", + "env": { + "GH_TOKEN": "$vault:GH_TOKEN" + } + } + } +} +``` + +After adding a `$vault:` reference, set the value and grant it to the configured Caplet: + +```sh +caplets vault set GH_TOKEN --grant github +caplets doctor +``` diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index 36cfc716..c15d39b6 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -10,7 +10,7 @@ handle that an agent can inspect, search, call, filter, and summarize in one wor Use Caplets with Codex, Claude, OpenCode, Pi, or any MCP client that can launch a local stdio server. -## Start here +## 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. @@ -66,6 +66,7 @@ should contain the OSV identifiers the agent found. - [Code Mode](/code-mode/) - learn the default agent workflow. - [Configuration](/configuration/) - configure user and project Caplets. - [Capabilities](/capabilities/) - add MCP, OpenAPI, GraphQL, HTTP, CLI, and shared Caplets. +- [Caplets Vault](/vault/) - store secret config values without relying on agent environment propagation. - [Agent integrations](/agent-integrations/) - use Codex, Claude, OpenCode, and Pi. - [Remote attach](/remote-attach/) - connect agents to a remote Caplets runtime. - [Troubleshooting](/troubleshooting/) - check config, auth, and runtime issues. diff --git a/apps/docs/src/content/docs/reference/caplet-files.mdx b/apps/docs/src/content/docs/reference/caplet-files.mdx index bb4a598e..dd57d29e 100644 --- a/apps/docs/src/content/docs/reference/caplet-files.mdx +++ b/apps/docs/src/content/docs/reference/caplet-files.mdx @@ -143,6 +143,24 @@ OpenAPI endpoint backend configuration for this Caplet. | `projectBinding` | Optional | object | Project Binding requirements for Caplets that need an attached project. | | `runtime` | Optional | object | Runtime feature and resource requirements for hosted execution. | +### `googleDiscoveryApi` + +Google Discovery API backend configuration for this Caplet. + +| Field | Status | Type | Description | +| --------------------- | -------- | ------- | ------------------------------------------------------------------------------------------ | +| `discoveryPath` | Optional | string | Local Google Discovery document path. | +| `discoveryUrl` | Optional | string | Remote Google Discovery document URL. | +| `baseUrl` | Optional | string | Override base URL for Google API requests. | +| `auth` | Required | object | Explicit Google API request auth config. Use \{"type":"none"\} for public APIs. | +| `requestTimeoutMs` | Optional | integer | Timeout in milliseconds for Google API HTTP requests. | +| `operationCacheTtlMs` | Optional | integer | Milliseconds Google Discovery operation metadata stays fresh. Set 0 to refresh every time. | +| `includeOperations` | Optional | array | Optional list of includeOperations. | +| `excludeOperations` | Optional | array | Optional list of excludeOperations. | +| `disabled` | Optional | boolean | When true, omit this Caplet from discovery. | +| `projectBinding` | Optional | object | Project Binding requirements for Caplets that need an attached project. | +| `runtime` | Optional | object | Runtime feature and resource requirements for hosted execution. | + ### `graphqlEndpoint` GraphQL endpoint backend configuration for this Caplet. diff --git a/apps/docs/src/content/docs/reference/config.mdx b/apps/docs/src/content/docs/reference/config.mdx index 3007256c..43eecf43 100644 --- a/apps/docs/src/content/docs/reference/config.mdx +++ b/apps/docs/src/content/docs/reference/config.mdx @@ -23,9 +23,9 @@ Minimal user config: ``` Keep `options.exposure` at the default `code_mode` unless your client cannot run Code -Mode. Add backend maps such as `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, -`httpApis`, `cliTools`, or `capletSets` only for the capability sources you actually -want agents to see. +Mode. Add backend maps such as `mcpServers`, `openapiEndpoints`, +`googleDiscoveryApis`, `graphqlEndpoints`, `httpApis`, `cliTools`, or `capletSets` only +for the capability sources you actually want agents to see. Stdio MCP server: @@ -153,6 +153,32 @@ OpenAPI endpoints keyed by stable Caplet ID. | `operationCacheTtlMs` | Optional | integer | Milliseconds OpenAPI operation metadata stays fresh. Set 0 to refresh every time. | | `disabled` | Optional | boolean | When true, omit this OpenAPI Caplet from discovery. | +### `googleDiscoveryApis` + +Google Discovery APIs keyed by stable Caplet ID. + +| Field | Status | Type | Description | +| --------------------- | -------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `name` | Required | string | Human-readable Google Discovery API display name. | +| `description` | Required | string | Capability description shown to agents before Google Discovery operations are disclosed. | +| `discoveryPath` | Optional | string | Local Google Discovery document path. | +| `discoveryUrl` | Optional | string | Remote Google Discovery document URL. | +| `baseUrl` | Optional | string | Override base URL for Google API requests. | +| `includeOperations` | Optional | array | Optional list of includeOperations. | +| `excludeOperations` | Optional | array | Optional list of excludeOperations. | +| `auth` | Required | object | Explicit Google API request auth config. Use \{"type":"none"\} for public APIs. | +| `tags` | Optional | array | Optional tags for grouping or searching Caplets. | +| `exposure` | Optional | "direct" \| "progressive" \| "code_mode" \| "direct_and_code_mode" \| "progressive_and_code_mode" | How this Caplet is exposed to agents. | +| `shadowing` | Optional | "forbid" \| "allow" | Whether attached local Caplets may shadow this remote Caplet ID. | +| `useWhen` | Optional | string | When agents should prefer this Caplet or configured action. | +| `avoidWhen` | Optional | string | When agents should avoid this Caplet or configured action. | +| `setup` | Optional | object | Optional setup and verification metadata. | +| `projectBinding` | Optional | object | Project Binding requirements for Caplets that need an attached project. | +| `runtime` | Optional | object | Runtime feature and resource requirements for hosted execution. | +| `requestTimeoutMs` | Optional | integer | Timeout in milliseconds for Google Discovery HTTP requests. | +| `operationCacheTtlMs` | Optional | integer | Milliseconds Google Discovery operation metadata stays fresh. Set 0 to refresh every time. | +| `disabled` | Optional | boolean | When true, omit this Google Discovery Caplet from discovery. | + ### `graphqlEndpoints` GraphQL endpoints keyed by stable Caplet ID. diff --git a/apps/docs/src/content/docs/troubleshooting.mdx b/apps/docs/src/content/docs/troubleshooting.mdx index 4a507622..eb1cce44 100644 --- a/apps/docs/src/content/docs/troubleshooting.mdx +++ b/apps/docs/src/content/docs/troubleshooting.mdx @@ -107,6 +107,33 @@ caplets doctor If the Caplet should never ask for newly inferred scopes, configure `auth.scopes` explicitly and keep `includeOperations` narrow. +### Vault-backed Caplet is quarantined + +Expected symptom: `doctor` reports a Vault issue, or a Caplet that references `$vault:...` +does not appear in the agent. + +List the current Vault keys and grants: + +```sh +caplets vault list +caplets vault access list +``` + +Then run the repair command from `doctor`. For a missing grant it will look like: + +```sh +caplets vault access grant GH_TOKEN github +``` + +If the Caplet runs in a remote or Cloud runtime, target that runtime: + +```sh +caplets vault access grant GH_TOKEN github --remote +``` + +Vault diagnostics never print raw values. Use [Caplets Vault](/vault/#diagnostics) for the +full setup and repair workflow. + ### Download returned an artifact Expected symptom: a tool result contains `body.artifact` instead of inline bytes or text. diff --git a/apps/docs/src/content/docs/vault.mdx b/apps/docs/src/content/docs/vault.mdx new file mode 100644 index 00000000..710ecaf4 --- /dev/null +++ b/apps/docs/src/content/docs/vault.mdx @@ -0,0 +1,216 @@ +--- +title: Caplets Vault +description: Store secret config values in Caplets instead of agent environment variables. +--- + +Caplets Vault stores secret-like string values in the runtime that uses them. Use it when +a Caplet needs a token, API key, URL, or other private config value and you do not want to +depend on the agent harness passing environment variables through to Caplets. + +Vault values are encrypted on disk. Config references stay in your Caplets config, while +the raw value stays in the local, self-hosted remote, or Cloud runtime that executes the +Caplet. + +## When to use Vault + +Use Vault for values that would otherwise appear in `env`, auth tokens, backend URLs, or +headers: + +```json +{ + "$schema": "https://caplets.dev/config.schema.json", + "mcpServers": { + "github": { + "name": "GitHub", + "description": "GitHub tools", + "command": "github-mcp", + "env": { + "GH_TOKEN": "$vault:GH_TOKEN" + } + } + } +} +``` + +Both forms are supported: + +```text +$vault:GH_TOKEN +${vault:GH_TOKEN} +``` + +Vault interpolation applies where runtime config values are resolved. Public metadata such +as Caplet names, descriptions, tags, and Markdown body text keeps the literal reference +text instead of resolving to a secret. + +## Set a local value + +The local/global Vault is the default target. Run `set` in an interactive shell to enter +the value at a hidden prompt: + +```sh +caplets vault set GH_TOKEN +``` + +Or pipe the value from another command: + +```sh +printf '%s\n' "$GH_TOKEN" | caplets vault set GH_TOKEN +``` + +Vault key names must match `^[A-Z_][A-Z0-9_]{0,127}$`. Values are strings and must be 64 +KiB or smaller. + +`set` does not overwrite an existing key unless you pass `--force`: + +```sh +caplets vault set GH_TOKEN --force +``` + +## Grant access + +A Vault value is not automatically available to every Caplet. Grant the key to the Caplet +that references it: + +```sh +caplets vault access grant GH_TOKEN github +``` + +For a one-key setup, set and grant in one command: + +```sh +caplets vault set GH_TOKEN --grant github +``` + +If the stored Vault key and the config reference differ, use `--as` for the reference name +used in config: + +```sh +caplets vault access grant GH_TOKEN_PERSONAL github-personal --as GH_TOKEN +caplets vault access grant GH_TOKEN_WORK github-work --as GH_TOKEN +``` + +Those grants let both Caplets use config that references `$vault:GH_TOKEN`, while each +Caplet resolves to a different stored value. + +Grants are scoped to the configured Caplet and the config file it came from. If the same +Caplet ID is shadowed across multiple config sources, resolve the active config before +granting access. + +## Inspect values and grants + +Vault commands show metadata by default and do not print raw values: + +```sh +caplets vault list +caplets vault get GH_TOKEN +caplets vault access list +``` + +Use `--json` when scripting: + +```sh +caplets vault access list --json +``` + +To reveal a raw value, pass `--show` explicitly: + +```sh +caplets vault get GH_TOKEN --show +``` + +Use raw reveal only in a human shell. Agent-facing Caplets surfaces resolve values into +runtime config but do not expose Vault management or raw reveal handles to agents. + +## Revoke and delete + +Revoke access for one Caplet reference: + +```sh +caplets vault access revoke GH_TOKEN github +``` + +If the Caplet referenced the key under a different name, pass the same `--as` value used +when granting: + +```sh +caplets vault access revoke GH_TOKEN_PERSONAL github-personal --as GH_TOKEN +``` + +Delete a stored value without revealing it: + +```sh +caplets vault delete GH_TOKEN +``` + +Deleting a value does not delete retained grants. If you set the value again later, the +existing grant can resolve again. + +## Remote and Cloud Vault + +Use `--remote` when the Caplet executes in a self-hosted remote runtime or Caplets Cloud: + +```sh +caplets vault set GH_TOKEN --remote +caplets vault access grant GH_TOKEN github --remote +``` + +The selected remote target uses the same Remote Profile and environment selection as +[Remote Attach](/remote-attach/). Log in first: + +```sh +caplets remote login https://caplets.example.com/caplets +CAPLETS_REMOTE_URL=https://caplets.example.com/caplets caplets vault list --remote +``` + +For Caplets Cloud: + +```sh +caplets remote login https://cloud.caplets.dev +CAPLETS_MODE=cloud CAPLETS_REMOTE_URL=https://cloud.caplets.dev caplets vault list --remote +``` + +Remote Vault values belong to the selected runtime. Local Caplets do not read, mirror, or +forward remote or Cloud Vault values. + +## Encryption key + +For local and self-hosted stores, Caplets writes encrypted values under the Caplets state +directory. On Linux and macOS the default local path is: + +```text +~/.local/state/caplets/vault +``` + +The Vault encryption key is minted automatically on first write and stored as a private +file with `0600` permissions where the platform supports POSIX permissions. You can +instead provide a 32-byte base64url key with `CAPLETS_ENCRYPTION_KEY`: + +```sh +CAPLETS_ENCRYPTION_KEY=... caplets vault list +``` + +Use the environment key only when you need to provide the same key from another secret +manager or deployment system. If the encryption key is unavailable or has the wrong +permissions, Vault-backed Caplets are quarantined until the key source is fixed. + +## Diagnostics + +Run `doctor` after adding Vault references: + +```sh +caplets doctor +``` + +Unset, missing, invalid, or ungranted Vault references quarantine only the affected +Caplet. `doctor` reports the key, Caplet, target, config path, and a repair command +without printing raw values. + +Example repair command: + +```sh +caplets vault access grant GH_TOKEN github +``` + +If the Caplet starts through an agent harness, restart the agent after setting or granting +Vault values so the Caplets runtime reloads clean config. diff --git a/caplets/github/CAPLET.md b/caplets/github/CAPLET.md index 1ace6cff..ae76912c 100644 --- a/caplets/github/CAPLET.md +++ b/caplets/github/CAPLET.md @@ -13,7 +13,7 @@ mcpServer: url: https://api.githubcopilot.com/mcp auth: type: bearer - token: $env:GH_TOKEN + token: $vault:GH_TOKEN --- # GitHub @@ -32,18 +32,24 @@ issues, pull requests, branches, commits, or review feedback. ## Use Carefully - Mutating operations can affect real repositories. Prefer read operations first. -- Use a least-privilege `GH_TOKEN`. +- Store a least-privilege `GH_TOKEN` in the Caplets Vault for the runtime where GitHub runs. - Do not ask the agent to expose token values, repository secrets, or private issue contents outside the intended conversation. ## Setup -Create a GitHub token with the minimum repository scopes needed for your workflow, then export it -before starting Caplets: +Create a GitHub token with the minimum repository scopes needed for your workflow, then store it in +the local/global Vault and grant this Caplet access: ```sh -export GH_TOKEN=github_pat_... +caplets vault set GH_TOKEN --grant github caplets serve ``` +For a self-hosted remote or hosted Cloud-backed runtime, write the value to that runtime instead: + +```sh +caplets vault set GH_TOKEN --remote --grant github +``` + This Caplet uses GitHub's hosted MCP endpoint at `https://api.githubcopilot.com/mcp`. diff --git a/caplets/github/README.md b/caplets/github/README.md index 5fe22bb2..ade76deb 100644 --- a/caplets/github/README.md +++ b/caplets/github/README.md @@ -3,10 +3,16 @@ This Caplet wraps GitHub's hosted MCP endpoint: ```sh -export GH_TOKEN=github_pat_... +caplets vault set GH_TOKEN --grant github caplets serve ``` +For self-hosted remote or hosted Cloud-backed runtime use: + +```sh +caplets vault set GH_TOKEN --remote --grant github +``` + Install it from this repo: ```sh diff --git a/docs/brainstorms/2026-06-22-caplets-vault-requirements.md b/docs/brainstorms/2026-06-22-caplets-vault-requirements.md new file mode 100644 index 00000000..abf0cb4a --- /dev/null +++ b/docs/brainstorms/2026-06-22-caplets-vault-requirements.md @@ -0,0 +1,252 @@ +--- +date: 2026-06-22 +topic: caplets-vault +--- + +# Caplets Vault Requirements + +## Summary + +Caplets Vault is a runtime-owned encrypted string store that config can reference with `$vault:NAME` or `${vault:NAME}`. Vault values replace fragile agent-harness environment propagation for secret-like config values while preserving the same interpolation exclusions and Caplet quarantine behavior as environment references. + +--- + +## Problem Frame + +Caplets currently relies on environment variables for many secret-like config values. That is a high-failure surface for agent harnesses because the harness that starts the agent may not pass the same environment through to the Caplets MCP process. + +Existing remote-auth work already moved remote credentials into Caplets-owned credential storage because agent env inheritance can be unreliable and can persist secrets in agent config. Caplets Vault applies the same product direction to ordinary backend config values: users set the value once in the runtime that needs it, and config references the stable key name. + +The known v1 catalog migration inventory is `caplets/github/CAPLET.md`, which uses `$env:GH_TOKEN` for a bearer token, plus the matching GitHub catalog setup docs that tell users to export `GH_TOKEN`. V1 migration succeeds when this inventory uses Vault references and setup language, and repository checks prevent new secret-like catalog `$env:` or bare `${NAME}` references after Vault ships. + +--- + +## Key Decisions + +- **Vault, not Secrets.** The feature is named Caplets Vault because it stores encrypted string values for config interpolation, not only API credentials. +- **One v1 value type.** Vault values are strings only in v1 because interpolation resolves into existing string config fields. +- **Runtime-owned stores.** Each runtime resolves `$vault:NAME` from its own Vault store. Local Caplets do not read, mirror, or forward remote or Cloud Vault values. +- **Local/global default.** `caplets vault` targets the local global Vault by default. `--global` is accepted as an explicit alias for the default, and `--remote` targets the authenticated selected remote runtime. +- **No project Vault scope.** Vault v1 has no project-scoped store and no committed encrypted `.caplets` Vault files. +- **Runtime-owned encryption key source.** Vault encryption uses runtime-owned key material. Local v1 installs mint and store a Vault encryption key in Caplets user data with owner-only permissions, while headless and self-hosted runtimes may provide key material through `CAPLETS_ENCRYPTION_KEY`. +- **Grant-level key mapping.** Vault keys remain runtime-global, but an access grant can satisfy a Caplet's referenced key name with a differently named stored Vault key. Users who need personal and work variants use distinct Caplet instance IDs such as `github-personal` and `github-work`. + +--- + +## Actors + +- A1. **Caplets user.** Sets, updates, lists, deletes, and occasionally reveals Vault values through the CLI. +- A2. **Caplets runtime.** Loads config, resolves `$vault:NAME`, and quarantines affected Caplets when Vault values cannot be resolved. +- A3. **Agent-facing client.** Uses exposed Caplets but cannot directly read raw Vault values. +- A4. **Remote runtime.** Owns and resolves its own Vault store when the user targets it with `--remote`. + +--- + +## Requirements + +**Vault Naming And Values** + +- R1. Caplets exposes the feature as Caplets Vault in user-facing CLI, docs, diagnostics, and requirements. +- R2. Config references Vault values with `$vault:NAME` or `${vault:NAME}`. +- R3. Vault values are strings in v1. +- R4. Vault references do not include provider qualification in v1. +- R5. Vault key names use an env-like grammar of uppercase ASCII letters, digits, and underscores, start with a letter or underscore, are at most 128 characters, and reject whitespace, control characters, path separators, interpolation delimiters, colons, and other provider-qualified syntax. + +**Interpolation And Config Loading** + +- R6. `$vault:NAME` and `${vault:NAME}` resolve everywhere `$env:NAME` and `${NAME}` currently resolve. +- R7. Vault interpolation follows the same public-metadata exclusions as environment interpolation, so public descriptions and tags preserve the reference text instead of expanding values. +- R8. Missing, locked, or unavailable Vault values quarantine only the affected Caplet. +- R9. Vault quarantine warnings show the Vault key name and config path needed for recovery. +- R10. Vault warnings, diagnostics, logs, and JSON output never print raw Vault values unless the user explicitly asks a human-facing CLI command to reveal one. + +**CLI Management** + +- R11. `caplets vault set NAME` stores a string in the local global Vault by default. +- R12. `caplets vault set NAME --global` is accepted as an explicit alias for the default local global target. +- R13. `caplets vault set NAME --remote` stores a string in the authenticated selected remote runtime's Vault. +- R14. `caplets vault set NAME` prompts without echo in an interactive shell when no non-argv value source is provided. +- R15. Non-interactive `caplets vault set NAME` fails clearly when no non-argv value source, such as stdin, is provided. +- R16. Raw Vault values are not accepted as command-line argument values. +- R17. Updating an existing Vault key requires `--force`. +- R18. `caplets vault list` shows key names plus safe metadata such as target, provider, status, and timestamps. +- R19. Human-facing CLI reveal requires an explicit show action, such as `caplets vault get NAME --show`. +- R20. Human-facing CLI commands may show raw values only through explicit reveal output; agent-facing MCP and Code Mode surfaces do not expose any operation that returns or reveals raw Vault values. +- R21. `caplets vault delete NAME` removes or invalidates the active stored value for the selected target without revealing the value. +- R22. After deletion, future references to that Vault key quarantine as missing unless a new value is set. +- R23. Vault status output makes any retained backup, recovery, or remote retention state explicit without revealing values. + +**Runtime And Remote Boundaries** + +- R24. Local Caplets resolve `$vault:NAME` from the local global Vault store. +- R25. Self-hosted remote Caplets resolve `$vault:NAME` from the self-hosted runtime's Vault store. +- R26. Cloud Caplets resolve `$vault:NAME` from the Cloud workspace Vault store. +- R27. `--remote` Vault management uses the same authenticated remote selection model as other remote CLI operations. +- R28. Local runtimes do not fetch, forward, or mirror remote or Cloud Vault values. +- R29. Vault setup docs name the active Vault target and the matching setup command for local, self-hosted remote, and Cloud-backed runtimes. +- R30. Unresolved-reference diagnostics name the Vault target that was checked and the command needed to set or grant the key for that target. +- R31. Remote Vault set and update operations use authenticated encrypted transport, and the CLI, proxy, and remote runtime do not log raw Vault values. + +**Security And Unlocking** + +- R32. Vault stores encrypted secret material on disk. +- R33. Local v1 installs mint a Vault encryption key when needed and store it in the Caplets user data directory with owner-only filesystem permissions. +- R34. `CAPLETS_ENCRYPTION_KEY` can provide Vault key material for headless, self-hosted, or runtime-managed deployments that do not want to persist a local key file. +- R35. Agent-facing startup does not prompt for Vault unlock input or receive Vault encryption key material. +- R36. Missing, unreadable, wrong-permissioned, or invalid Vault encryption key material is treated like an unresolved reference during config loading. +- R37. Vault status output reports enough information to diagnose key-source, unavailable, missing, and healthy states without revealing values. +- R38. V1 keeps the encryption key source boundary narrow enough that OS keychain-backed key storage can be added later without changing `$vault:NAME`, Vault CLI commands, or stored value semantics. + +**Future Provider Shape** + +- R39. V1 ships with the built-in Caplets encrypted Vault provider only. +- R40. V1 does not add external-provider abstractions or plumbing unless they are required for the built-in Caplets encrypted Vault provider. + +**Catalog Migration** + +- R41. Catalog Caplets under `caplets/` use Vault references instead of environment references for user-supplied secret-like values. +- R42. Catalog documentation tells users to set required secret-like values through `caplets vault set` instead of exporting environment variables. +- R43. Catalog migration preserves non-secret auth flows such as OAuth commands that already store credentials in Caplets-owned auth state. +- R44. The built-in catalog does not introduce new `$env:` or bare `${NAME}` references for secret-like values after Vault ships. + +**Vault Access Grants** + +- R45. Vault value resolution requires an access grant for the Vault key, Caplet ID, and config origin; knowing the key name is not enough to resolve the value. +- R46. Unauthorized Vault references quarantine the affected Caplet like unresolved Vault references, while diagnostics distinguish ungranted access from missing, locked, or unavailable values. +- R47. `caplets vault access grant NAME ` grants a Caplet access to resolve one Vault key from its current config origin. +- R48. `caplets vault access revoke NAME ` removes a Caplet's access to one Vault key. +- R49. `caplets vault access list NAME` lists the Caplets that can resolve one Vault key without revealing the value. +- R50. `caplets vault access list --caplet ` lists the Vault keys that one Caplet can resolve without revealing values. +- R51. `caplets vault set NAME --grant ` stores a value and grants access in one interactive human setup flow. +- R52. `caplets vault access grant NAME --as ` grants one stored Vault key as the value for the named `$vault:` reference in that Caplet's config. +- R53. `--as` defaults to the stored Vault key name, so `caplets vault access grant GH_TOKEN github` is equivalent to granting `GH_TOKEN` as `GH_TOKEN`. +- R54. Users who need different values for the same catalog reference name configure distinct Caplet instance IDs and grant each instance a different stored Vault key with `--as`. +- R55. `caplets doctor` reports Caplets that reference Vault keys without matching access grants and prints the exact repair command. +- R56. `caplets attach` and runtime startup surface ungranted Vault references as preflight diagnostics before agent harnesses silently lose affected Caplets. + +--- + +## Key Flows + +- F1. Local Vault setup + - **Trigger:** A user wants a local Caplet config to stop depending on harness-passed environment variables. + - **Actors:** A1, A2 + - **Steps:** The user runs `caplets vault set NAME`, enters the value through a non-echoing prompt, and references `$vault:NAME` or `${vault:NAME}` in config. + - **Outcome:** The local runtime resolves the value from the local global Vault when it loads the Caplet. + - **Covered by:** R2, R11, R14, R24 + +- F2. Remote Vault setup + - **Trigger:** A user wants a self-hosted or Cloud-backed Caplet to use a Vault value in the runtime where it executes. + - **Actors:** A1, A4 + - **Steps:** The user authenticates the remote profile, selects the remote through existing remote mode, and runs `caplets vault set NAME --remote`. + - **Outcome:** The remote runtime stores and resolves its own Vault value without the local runtime learning the value. + - **Covered by:** R13, R25, R26, R27, R28, R31 + +- F3. Unresolved Vault reference + - **Trigger:** Config references `$vault:NAME` or `${vault:NAME}`, but the runtime cannot resolve that key. + - **Actors:** A2, A3 + - **Steps:** The runtime loads config, detects the unresolved Vault reference, and quarantines only the affected Caplet. + - **Outcome:** Other valid Caplets remain available, and diagnostics identify the key name and config path without revealing any value. + - **Covered by:** R8, R9, R10, R30, R36, R37 + +- F4. Human reveal + - **Trigger:** A user needs to inspect a stored value. + - **Actors:** A1 + - **Steps:** The user runs a CLI get command and supplies the explicit show action. + - **Outcome:** The CLI prints the value only because the local human-facing command requested reveal. + - **Covered by:** R19, R20 + +- F5. Catalog Caplet setup + - **Trigger:** A user installs or reads a catalog Caplet that needs a user-supplied token. + - **Actors:** A1, A2 + - **Steps:** The catalog Caplet references `$vault:NAME` or `${vault:NAME}`, and its setup docs instruct the user to store the value with `caplets vault set NAME`. + - **Outcome:** The Caplet works in agent harnesses that do not propagate shell environment variables to the Caplets MCP process. + - **Covered by:** R41, R42, R44 + +- F6. Vault access grant setup + - **Trigger:** A Caplet config references a Vault key that exists but has not been granted to that Caplet. + - **Actors:** A1, A2 + - **Steps:** The user runs `caplets vault access grant NAME ` or uses `caplets vault set NAME --grant ` during setup. + - **Outcome:** The runtime can resolve the Vault key only for the granted Caplet and config origin. + - **Covered by:** R45, R47, R51, R53 + +- F7. Ungranted Vault reference diagnosis + - **Trigger:** A runtime sees `$vault:NAME` or `${vault:NAME}` in a Caplet without a matching access grant. + - **Actors:** A1, A2, A3 + - **Steps:** `caplets doctor`, `caplets attach`, or runtime startup reports the ungranted reference and shows the repair command. + - **Outcome:** The affected Caplet fails closed without hiding the reason from the human setup path. + - **Covered by:** R46, R55, R56 + +- F8. Grant-level key mapping + - **Trigger:** A user wants two configured Caplet instances to satisfy the same catalog reference name with different stored Vault keys. + - **Actors:** A1, A2 + - **Steps:** The user configures distinct Caplet instance IDs such as `github-personal` and `github-work`, then grants `GH_TOKEN_PERSONAL` to `github-personal --as GH_TOKEN` and `GH_TOKEN_WORK` to `github-work --as GH_TOKEN`. + - **Outcome:** Each Caplet instance resolves `$vault:GH_TOKEN` to its granted stored key without project-scoped Vault state. + - **Covered by:** R52, R54 + +--- + +## Acceptance Examples + +- AE1. **Covers R2, R5.** Given a user sets or references a Vault key, when the key uses lowercase letters, whitespace, path separators, interpolation delimiters, colons, control characters, provider-qualified syntax, starts with a digit, or exceeds 128 characters, then Caplets rejects the key before storing or resolving it. +- AE2. **Covers R6, R7.** Given a config field that currently interpolates `$env:NAME` or `${NAME}`, when it is changed to `$vault:NAME` or `${vault:NAME}`, then the Vault value resolves in the same class of config fields and remains unexpanded in public metadata fields. +- AE3. **Covers R8, R9, R10.** Given two configured Caplets and only one references a missing Vault key, when config loads, then only the affected Caplet is quarantined and the warning names `NAME` without printing its value. +- AE4. **Covers R11, R12, R14, R15, R16, R17.** Given a user stores a local Vault value, when they use an interactive shell then Caplets prompts without echo, when they use a non-interactive shell then Caplets requires a non-argv source such as stdin, and when the key already exists then Caplets refuses to overwrite it without `--force`. +- AE5. **Covers R13, R25, R26, R27, R28.** Given an authenticated selected remote, when the user runs `caplets vault set NAME --remote`, then the remote runtime stores the value and the local runtime does not persist a copy. +- AE6. **Covers R19, R20.** Given an agent-facing Code Mode session can access Caplet handles, when it searches for ways to read or reveal Vault values directly, then no raw Vault value operation is exposed. +- AE7. **Covers R21, R22, R23.** Given a Vault key is deleted, when config later references that key, then the affected Caplet quarantines as missing and status output explains any retained recovery state without revealing values. +- AE8. **Covers R29, R30.** Given a user reads setup docs or diagnostics for a missing Vault value, when the value belongs to a remote or Cloud runtime, then the output names that target and shows the matching remote setup command rather than a local-only command. +- AE9. **Covers R31.** Given a user stores a remote Vault value, when the CLI sends it to the remote runtime, then the operation uses authenticated encrypted transport and raw values are absent from CLI, proxy, and remote logs. +- AE10. **Covers R32, R33, R34, R35, R36, R37, R38.** Given a runtime needs Vault key material, when it is a local install then Caplets can use a minted owner-only key file, when it is a headless or self-hosted deployment then `CAPLETS_ENCRYPTION_KEY` can provide key material, and when key material is unavailable or invalid then agent-facing startup does not prompt and affected Caplets quarantine with diagnostic status. +- AE11. **Covers R41, R42, R44.** Given a catalog Caplet currently needs a user-supplied token, when Vault ships, then the Caplet references Vault and its setup docs no longer instruct the user to export an environment variable for that token. +- AE12. **Covers R43.** Given a catalog Caplet already uses Caplets-owned OAuth auth state, when the catalog migration runs, then that Caplet keeps the OAuth setup flow instead of replacing it with Vault. +- AE13. **Covers R45, R46.** Given a Caplet references an existing Vault key without a matching access grant, when config loads, then that Caplet is quarantined as ungranted rather than resolving the value. +- AE14. **Covers R47, R48, R49, R50, R52, R53, R54.** Given a user manages Vault access, when they grant, revoke, list access, or map a stored key to a referenced key with `--as`, then Caplets changes or reports only access metadata and never prints raw Vault values. +- AE15. **Covers R55, R56.** Given an agent harness starts Caplets with an ungranted Vault reference, when preflight or doctor runs, then the output names the Caplet, key, target, and repair command before the missing Caplet looks like a generic startup failure. +- AE16. **Covers R52, R54.** Given `github-personal` and `github-work` both reference `$vault:GH_TOKEN`, when the user grants `GH_TOKEN_PERSONAL` to `github-personal --as GH_TOKEN` and `GH_TOKEN_WORK` to `github-work --as GH_TOKEN`, then each Caplet instance resolves its own stored key without adding project-scoped Vault state. + +--- + +## Scope Boundaries + +- Project-scoped Vault stores are out of scope for v1. +- Committed encrypted Vault files under `.caplets` are out of scope. +- Remapping one Caplet instance into multiple logical identities at grant time is out of scope; users configure distinct Caplet instance IDs for distinct account contexts. +- `$secrets:NAME` and `${secrets:NAME}` are not part of the feature; naming is Vault throughout. +- Structured values and JSON blobs are out of scope for v1. +- Provider-qualified syntax such as `$vault:aws/NAME` is out of scope for v1. +- External Vault providers are future work, not part of the built-in v1 provider. +- Future integrations with external providers such as AWS Secrets Manager or HashiCorp Vault are out of scope for v1. +- OS keychain-backed Vault key storage is future work, not part of the required v1 unlock path. +- Agent-facing APIs that reveal raw Vault values are out of scope. +- Replacing existing Caplets-owned OAuth flows with Vault is out of scope. + +--- + +## Dependencies / Assumptions + +- The implementation can reuse the existing missing-reference quarantine model used for `$env:` references. +- Remote Vault management depends on the existing authenticated remote selection model. +- Cloud Vault behavior is treated as a remote-runtime concern, not as a local secret-forwarding feature. +- Catalog migration includes the known v1 inventory in `caplets/github/CAPLET.md` and its matching GitHub catalog setup docs. +- Vault access grants are intended to be configured before runtime startup; agent-facing startup must not prompt for grants or receive raw values. +- Planning must choose the concrete local key-file storage path, permission checks, and `CAPLETS_ENCRYPTION_KEY` validation rules. + +--- + +## Sources / Research + +- `packages/core/src/config.ts` currently scans and interpolates `${NAME}` and `$env:NAME` references, and formats missing environment warnings. +- `packages/core/test/config.test.ts` verifies missing environment references quarantine only affected Caplets and preserve public metadata references. +- `packages/core/src/cli.ts` shows existing `--global` and `--remote` targeting patterns for config/auth operations. +- `packages/core/src/remote/credential-store.ts` and `packages/core/src/cloud-auth/store.ts` show adjacent Caplets-owned credential storage patterns. +- `caplets/github/CAPLET.md` currently uses `$env:GH_TOKEN` and export-based setup language for its bearer token. +- `docs/solutions/integration-issues/stale-remote-profile-credentials-refresh.md` records why env-based remote setup was the wrong product direction for remote credentials. + +--- + +## Deferred / Open Questions + +### From 2026-06-22 review + +None. diff --git a/docs/plans/2026-06-22-001-feat-caplets-vault-plan.md b/docs/plans/2026-06-22-001-feat-caplets-vault-plan.md new file mode 100644 index 00000000..3dc02122 --- /dev/null +++ b/docs/plans/2026-06-22-001-feat-caplets-vault-plan.md @@ -0,0 +1,498 @@ +--- +title: "feat: Add Caplets Vault" +type: feat +date: 2026-06-22 +origin: docs/brainstorms/2026-06-22-caplets-vault-requirements.md +--- + +# feat: Add Caplets Vault + +## Summary + +Implement Caplets Vault as a runtime-owned encrypted string store for `$vault:NAME` and `${vault:NAME}` config interpolation. The work replaces fragile secret-like catalog env references with Vault values, adds explicit human CLI management, enforces per-Caplet access grants, and keeps local, self-hosted remote, and Cloud runtime stores separate. + +--- + +## Problem Frame + +Caplets currently depends on process environment propagation for secret-like config values. That is brittle for agent harnesses because the shell that sets a value is often not the process that starts the Caplets MCP runtime. + +Vault follows the same product direction as Remote Login: Caplets owns long-lived sensitive runtime state, agent configs carry stable references, and unresolved sensitive state quarantines only affected Caplets. The feature must preserve the existing missing-env quarantine ergonomics while adding encrypted at-rest storage, grant checks, remote management, and catalog migration (see origin: `docs/brainstorms/2026-06-22-caplets-vault-requirements.md`). + +--- + +## Requirements + +**Config interpolation and quarantine** + +- PR1. Config supports `$vault:NAME` and `${vault:NAME}` wherever `$env:NAME` and `${NAME}` currently interpolate, except public metadata fields. +- PR2. Vault key names use the origin env-like uppercase grammar and fail before storage, grants, or resolution when invalid. +- PR3. Missing, locked, unavailable, invalid-key-source, or ungranted Vault references quarantine only the affected Caplet and preserve valid siblings. +- PR4. Vault diagnostics name key, Caplet, target, config path, and recovery command without printing raw values. + +**Encrypted local runtime store** + +- PR5. Local/global Vault management stores encrypted string values under Caplets-owned user data with owner-only permissions. +- PR6. Local v1 installs mint a Vault encryption key when needed, or use `CAPLETS_ENCRYPTION_KEY` when provided. +- PR7. Invalid, unreadable, missing, or wrong-permissioned Vault key material fails closed as unresolved Vault state during config loading. +- PR8. The local store keeps key-source and encrypted-store boundaries narrow enough to add OS keychain key storage later. + +**CLI and remote control** + +- PR9. `caplets vault` targets local/global by default, accepts `--global` as an explicit alias, and routes `--remote` through the authenticated selected runtime. +- PR10. `caplets vault set NAME` prompts without echo in interactive shells when no non-argv value source is provided, rejects missing noninteractive input, rejects raw argv values, and requires `--force` for updates. +- PR11. `caplets vault list`, `get NAME`, and `delete NAME` expose only safe metadata; `get NAME --show` reveals the raw value only for an explicit human-facing request. +- PR12. Remote Vault set and update operations travel through authenticated encrypted control paths, execute on the selected self-hosted runtime or hosted Cloud target contract, and do not persist or log raw values locally. +- PR21. Vault string values are limited to 64 KiB of plaintext in v1 and fail before encryption, storage, or remote transport when oversized. + +**Access grants** + +- PR13. Vault resolution requires an access grant for stored Vault key, Caplet ID, referenced key name, and config origin. +- PR14. `caplets vault access grant|revoke|list` manages grant metadata without revealing raw values, and accepts `--remote` to manage grant metadata in the selected runtime target. +- PR15. `caplets vault access grant NAME --as ` maps a runtime-global stored key to the Caplet's referenced key name; `caplets vault access grant NAME --remote` performs the same grant against a remote Vault value in the selected runtime target. +- PR16. `caplets vault set NAME --grant ` supports the ergonomic setup path for common one-key Caplets, including `caplets vault set NAME --remote --grant ` for remote Vault values. + +**Agent exposure, docs, and catalog** + +- PR17. Agent-facing MCP and Code Mode surfaces expose no raw Vault reveal operation. +- PR18. `caplets doctor`, `caplets attach`, and runtime startup surface unresolved and ungranted Vault references before harnesses silently lose Caplets. +- PR19. Catalog Caplets under `caplets/` use Vault references for user-supplied secret-like values, starting with GitHub's `GH_TOKEN`. +- PR20. Docs and generated references teach `caplets vault set` and grants instead of exporting secret-like environment variables for catalog Caplets. + +--- + +## Key Technical Decisions + +- KTD1. **Add a core Vault module instead of embedding storage in config or CLI code.** Storage, encryption, key-source loading, grant metadata, and redacted status are shared by config loading, CLI commands, doctor, and remote control. +- KTD2. **Treat Vault references as a sibling reference class to missing env references.** The current quarantine path already removes only affected Caplet backend entries and preserves public metadata, so Vault should extend that model rather than create a second config-loading pipeline. +- KTD3. **Use authenticated encryption with versioned records.** Node's stable `node:crypto` API supports the required cryptographic primitives, and a versioned envelope lets future keychain or provider work preserve stored semantics. +- KTD4. **Store key material separately from encrypted values.** The local minted key or `CAPLETS_ENCRYPTION_KEY` unlocks encrypted records; the encrypted values live in a separate store with safe metadata, matching OWASP's key-management guidance to separate keys from protected secrets. +- KTD5. **Make grant metadata part of resolution, not only CLI policy.** A user who knows a Vault key name still cannot resolve it unless config origin and Caplet ID match an access grant. +- KTD6. **Execute remote Vault management in the selected runtime target.** `--remote` follows the existing self-hosted remote-control pattern where available and the hosted Cloud client contract where selected, but the target runtime owns Vault storage and grant metadata so local Caplets never mirror remote values. Hosted Cloud server/runtime implementation lives outside this repository; this repository owns the CLI/client request shape, target selection, redaction, and diagnostics. +- KTD7. **Keep raw reveal out of agent-facing APIs.** Human-facing CLI reveal is explicit with `--show`; Code Mode and MCP wrapper surfaces should only see resolved downstream backend behavior, never a Vault read primitive. +- KTD8. **Make catalog migration narrow and checked.** Update the GitHub catalog Caplet and docs now, then add a repo check that prevents new secret-like catalog env references after Vault ships while leaving benchmark harness env vars alone. +- KTD9. **Preserve synchronous config loading.** Existing config loaders and tests are synchronous, so the built-in v1 Vault provider should use sync-capable local file and crypto APIs rather than making config parsing async across the repo. +- KTD10. **Pin the v1 crypto and key-material contract before implementation.** Local records use AES-256-GCM with 32-byte keys, 96-bit random nonces, explicit auth tags, and a versioned JSON envelope. Minted key files store base64url-encoded 32-byte random key material, and `CAPLETS_ENCRYPTION_KEY` is accepted only when it decodes to exactly 32 bytes. +- KTD11. **Define grants against canonical loader-derived origins.** Grant records store source kind plus the canonical origin identity produced by config loading; grant commands resolve the active config source for the Caplet ID before writing metadata, and resolution matches stored key, referenced key name, Caplet ID, and canonical origin. +- KTD12. **Keep `parseConfig` pure unless a Vault resolver is provided.** Bare `parseConfig(input)` performs no implicit global Vault IO. Source-aware loaders pass `{ origin, vaultResolver, warnings }` or an equivalent internal preflight context, and tests pass explicit fixture resolvers. + +--- + +## High-Level Technical Design + +### Component Topology + +```mermaid +flowchart TB + CLI["caplets vault CLI"] --> Target{"target"} + Target -->|global/default| LocalVault["local Vault service"] + Target -->|remote| RemoteControl["remote control request"] + RemoteControl --> RemoteRuntime["selected remote runtime Vault service"] + + ConfigLoader["config loader"] --> RefScanner["reference scanner"] + RefScanner --> EnvResolver["env resolver"] + RefScanner --> VaultResolver["Vault resolver"] + VaultResolver --> GrantStore["grant metadata"] + VaultResolver --> LocalVault + VaultResolver --> RemoteRuntime + + LocalVault --> KeySource["key source"] + KeySource --> KeyFile["minted key file"] + KeySource --> EnvKey["CAPLETS_ENCRYPTION_KEY"] + LocalVault --> EncryptedStore["encrypted value store"] + RemoteRuntime --> RemoteStore["runtime-owned encrypted store"] + + RefScanner --> Quarantine["affected Caplet quarantine"] + Quarantine --> Diagnostics["doctor/attach/startup diagnostics"] +``` + +### Resolution Flow + +```mermaid +flowchart TB + Load["load global/project config and Caplet files"] --> Scan["scan interpolated fields"] + Scan --> Public{"public metadata?"} + Public -->|yes| Preserve["preserve reference text"] + Public -->|no| Classify{"reference type"} + Classify -->|env| EnvCheck["resolve env or missing-env quarantine"] + Classify -->|vault| KeyCheck["validate Vault key grammar"] + KeyCheck --> GrantCheck["check stored-key/reference-key/config-origin grant"] + GrantCheck --> StoreCheck["load and decrypt Vault value"] + StoreCheck --> Interpolate["interpolate resolved value"] + GrantCheck -->|missing grant| Quarantine["quarantine affected Caplet"] + StoreCheck -->|missing/locked/unavailable| Quarantine + Quarantine --> Warning["redacted warning with repair command"] +``` + +### CLI Targeting + +```mermaid +flowchart TB + Command["caplets vault ..."] --> Flags{"flags"} + Flags -->|none or --global| Local["local global Vault"] + Flags -->|--remote| Selection["resolve authenticated selected remote"] + Selection --> Request["remote control vault_* command"] + Request --> Server["server-side Vault operation"] + Server --> RedactedResult["redacted result or explicit human reveal"] + Local --> RedactedResult +``` + +--- + +## Implementation Units + +### U1. Vault Storage, Key Source, and Grant Model + +**Goal:** Add the core local Vault implementation that stores encrypted string values, validates key names, manages key material, and records grant metadata. + +**Requirements:** PR2, PR5, PR6, PR7, PR8, PR13, PR14, PR15, PR21 + +**Dependencies:** None + +**Files:** + +- `packages/core/src/vault/index.ts` +- `packages/core/src/vault/types.ts` +- `packages/core/src/vault/keys.ts` +- `packages/core/src/vault/store.ts` +- `packages/core/src/vault/crypto.ts` +- `packages/core/src/vault/access.ts` +- `packages/core/src/config/paths.ts` +- `packages/core/test/vault.test.ts` + +**Approach:** Introduce a small built-in file-backed Vault module with minimal injected dependencies for tests and runtime wiring, not a general external-provider framework. Use sync-capable local file and crypto operations so config loading does not become async. Use the Caplets state/auth directory pattern for local paths, owner-only directories and files where POSIX permissions are available, AES-256-GCM encrypted records with versioned JSON envelopes, and redacted status objects. Store grant records separately from encrypted value records so access metadata can be listed without touching secret material. Grant records include the stored key name, referenced key name, Caplet ID, canonical loader-derived config origin, and timestamps. Reject oversized plaintext values before encryption and oversized encrypted records before decryption. + +**Patterns to follow:** `packages/core/src/remote/credential-store.ts` for atomic owner-only file writes; `packages/core/src/cloud-auth/store.ts` for redacted status shape; `packages/core/test/remote-profiles.test.ts` for permission assertions. + +**Test scenarios:** + +- Covers AE1. Given invalid Vault key names with lowercase, path separators, colons, interpolation delimiters, leading digits, whitespace, control characters, or more than 128 characters, validation rejects them before store or grant mutation. +- Covers AE10. Given no existing local key and no `CAPLETS_ENCRYPTION_KEY`, saving a value mints a key file with owner-only permissions and writes encrypted value material without plaintext. +- Covers AE10. Given `CAPLETS_ENCRYPTION_KEY` is provided and decodes to exactly 32 bytes, Vault uses it without creating or reading the minted key file. +- Covers AE10. Given `CAPLETS_ENCRYPTION_KEY` is an invalid encoding, passphrase-like string, short key, long key, or otherwise does not decode to exactly 32 bytes, Vault fails closed before encryption, decryption, or grant mutation. +- Covers AE10. Given the key file is missing, unreadable, malformed, too broadly permissioned on a POSIX platform, or has an unsupported key format version, status reports an unavailable key source and value resolution fails closed. +- Covers AE10. Given an encrypted record is tampered with, has an invalid auth tag, or declares an unsupported envelope version, decryption fails closed and raw plaintext is not emitted. +- Covers AE14. Given grants exist for multiple Caplets and stored keys, grant listing returns Caplet IDs, referenced key names, config origins, and timestamps without raw values. +- Covers AE7. Given a stored key is deleted, Vault removes or invalidates the active value record without returning plaintext and leaves only safe retained-state metadata where applicable. +- Given two grants use the same Caplet ID and referenced key name from different canonical config origins, resolution for one origin cannot use the other origin's grant. +- Given a stored key is overwritten with `force`, the encrypted record changes and existing grants remain metadata-only. +- Given a Vault value exceeds 64 KiB of plaintext, storage rejects it before encryption and reports a safe validation error. + +**Verification:** The Vault module can be tested without loading Caplet config, encrypted files never contain plaintext fixture values, and redacted status is sufficient for CLI and doctor callers. + +### U2. Vault-Aware Config Interpolation and Quarantine + +**Goal:** Resolve Vault references during config loading while preserving existing env interpolation behavior and public metadata exclusions. + +**Requirements:** PR1, PR2, PR3, PR4, PR13, PR15, PR18 + +**Dependencies:** U1 + +**Files:** + +- `packages/core/src/config.ts` +- `packages/core/test/config.test.ts` + +**Approach:** Generalize the existing missing-env scan into a reference-resolution preflight that recognizes env and Vault references. Public metadata remains non-interpolated. Define one synchronous resolver contract such as `parseConfig(input, { origin, vaultResolver, warnings })` or an equivalent internal preflight API. Bare `parseConfig(input)` does not perform implicit global Vault IO; source-aware loaders pass the real `ConfigSource`, and tests pass explicit fixture resolvers. For Vault references, compute the canonical config source origin and Caplet backend ID before interpolation, require a matching grant, resolve `--as` mappings from referenced key to stored key, and quarantine the backend entry with a recoverable warning when any Vault state is unresolved. + +**Patterns to follow:** `quarantineMissingEnvCaplets`, `missingEnvReferences`, `interpolateConfig`, and the local overlay quarantine tests in `packages/core/test/config.test.ts`. + +**Test scenarios:** + +- Covers AE2. Given a field that currently interpolates `$env:NAME` or `${NAME}`, replacing it with `$vault:NAME` or `${vault:NAME}` resolves in the same field class. +- Covers AE2. Given descriptions or tags contain `$vault:NAME` or `${vault:NAME}`, config loading preserves the literal reference text. +- Covers AE3. Given one Caplet references a missing Vault key and another is valid, only the affected Caplet is removed and the valid sibling remains available. +- Covers AE13. Given a Vault key exists but the Caplet lacks a matching grant for its ID and config origin, config loading quarantines the Caplet as ungranted. +- Covers AE13. Given the same Caplet ID and `$vault` reference appear from a different config origin than the grant, config loading quarantines the Caplet as ungranted. +- Covers AE16. Given two Caplet instance IDs both reference `$vault:GH_TOKEN`, grants with different stored keys and `--as GH_TOKEN` resolve each instance to the correct value. +- Given bare `parseConfig(input)` sees a Vault reference, it preserves purity by using no global Vault IO and routing resolution through the explicit preflight/resolver path. +- Given env and Vault references appear in the same config tree, missing env and unresolved Vault diagnostics keep their separate reason labels and do not leak values. + +**Verification:** Existing env interpolation tests still pass unchanged, Vault failures appear as recoverable source warnings, and warning text contains key names and config paths but no raw Vault values. + +### U3. Local Vault CLI Commands + +**Goal:** Add the human-facing `caplets vault` command group for local/global set, list, get, delete, and access management. + +**Requirements:** PR9, PR10, PR11, PR14, PR15, PR16, PR17, PR21 + +**Dependencies:** U1 + +**Files:** + +- `packages/core/src/cli.ts` +- `packages/core/src/cli/vault.ts` +- `packages/core/test/cli.test.ts` + +**Approach:** Keep command parsing in `cli.ts` and move operation formatting into `cli/vault.ts`. Parse target flags with the same mutation-target rules as add/install/init, but default Vault to global/local and accept `--global` as an explicit alias. Read values from a hidden interactive prompt or stdin-style non-argv source; reject raw value arguments; reject values over 64 KiB before storage or remote send; require `--force` when updating an existing key. Make `get` reveal only when `--show` is supplied. For `set --grant` and `access grant`, load config with sources, resolve the active source for ``, reject missing, duplicate, or shadowed IDs with repair guidance, and store the canonical origin in the grant. `set --grant` is atomic from the user's perspective: either value storage and grant mutation both succeed, or newly-created state rolls back and one actionable error is reported. + +**Patterns to follow:** `createSetupPromptHandle`, `readHiddenPrompt`, `readStdin`, `parseMutationTarget`, and auth target command formatting in `packages/core/src/cli.ts`. + +**Test scenarios:** + +- Covers AE4. Given an interactive shell and no non-argv value source, `caplets vault set NAME` prompts without echo and stores the entered value. +- Covers AE4. Given noninteractive execution without stdin or an equivalent injected value reader, `set` fails with a clear request error. +- Covers AE4. Given an existing key, `set NAME` refuses to overwrite and `set NAME --force` updates it. +- Given `caplets vault get NAME` without `--show`, output includes safe metadata but not the raw value. +- Given `caplets vault get NAME --show`, output reveals the raw value only in the human-facing CLI response. +- Covers AE14. Given grant, revoke, list-by-key, list-by-Caplet, and `set --grant`, commands mutate or report only access metadata. +- Covers AE7. Given `caplets vault delete NAME`, output names the key and retained-state status without revealing the raw value. +- Covers AE16. Given global and project configs shadow the same Caplet ID, `set --grant` and `access grant` either resolve the active source deterministically or reject ambiguity with repair guidance before writing grant metadata. +- Given `set --grant` stores a value but grant mutation fails, newly-created value state rolls back or is invalidated before the command returns failure. +- Given `set --grant` creates a grant but value storage fails, newly-created grant state rolls back before the command returns failure. +- Given stdin or prompt input exceeds 64 KiB, `set` fails before storage and output does not include the raw value. + +**Verification:** CLI output is stable in plain and JSON formats, command errors are actionable, and tests assert raw fixture values are absent from all non-show outputs. + +### U4. Remote Vault Control + +**Goal:** Route `caplets vault --remote` management to the selected self-hosted runtime or hosted Cloud client contract without local persistence or mirroring. + +**Requirements:** PR9, PR11, PR12, PR14, PR18, PR21 + +**Dependencies:** U1, U3 + +**Files:** + +- `packages/core/src/remote-control/types.ts` +- `packages/core/src/remote-control/dispatch.ts` +- `packages/core/src/remote-control/client.ts` +- `packages/core/src/remote/selection.ts` +- `packages/core/src/cloud/client.ts` +- `packages/core/src/cloud/runtime-http.ts` +- `packages/core/src/cli.ts` +- `packages/core/test/remote-control-dispatch.test.ts` +- `packages/core/test/remote-control-client.test.ts` +- `packages/core/test/cloud-client.test.ts` +- `packages/core/test/cli-remote.test.ts` + +**Approach:** Add remote-control commands for Vault set/list/get/delete/access operations for self-hosted remote targets, and add the hosted Cloud client request/response contract for the same Vault operations. This repository should drive hosted Cloud target selection, request shapes, response handling, diagnostics, and no-local-mirroring tests; the hosted Cloud runtime/server store implementation is outside this repository. The client sends values only in authenticated request bodies for `set`, rejects values over 64 KiB before transport, and only returns raw values for explicit human `get --show` responses. `caplets vault access grant NAME --remote`, `revoke --remote`, and `list --remote` mutate or read grant metadata on the selected runtime target, not the local Vault grant store. The dispatcher executes against the server-side Vault store for self-hosted targets and rejects local-only path or destination parameters. Vault request types carry sensitive-field or sensitive-value metadata so the client, proxy, dispatcher, and runtime logging redact exact raw fixture values on both success and failure paths before generic credential-pattern redaction. Remote raw reveal is a privileged human CLI context, is not advertised through native service, MCP, Code Mode, progressive tools, or generic remote manifests, and dispatcher/client layers reject reveal requests without that context. + +**Patterns to follow:** Remote `init`, `install`, `add`, and `auth_*` dispatch in `packages/core/src/remote-control/dispatch.ts`; stale credential learning in `docs/solutions/integration-issues/stale-remote-profile-credentials-refresh.md`. + +**Test scenarios:** + +- Covers AE5. Given `caplets vault set NAME --remote`, the local CLI sends a remote-control request and the local Vault store remains unchanged. +- Covers AE5. Given hosted Cloud is the selected target, `caplets vault --remote` uses the Cloud Vault client contract and diagnostics without writing to the local Vault store. +- Covers AE9. Given a remote set operation succeeds or fails and the value appears in logs, diagnostics, or a thrown server error, client, proxy, dispatcher, and runtime output redact the exact raw value. +- Covers AE9. Given an unlabeled fixture value appears verbatim in a thrown remote error, operation-scoped sensitive-value redaction removes it before generic credential-pattern redaction. +- Covers AE14. Given `caplets vault access grant NAME --remote`, the local CLI sends a target-owned grant request, the selected runtime stores the grant metadata, the local grant store remains unchanged, and no raw value is returned. +- Covers AE14. Given remote access list/revoke commands, target-owned grant metadata changes or reports safely and raw values are not returned. +- Given a remote profile is unauthenticated or missing, `--remote` Vault commands fail with the same recovery model as other remote CLI operations. +- Given a remote request body exceeds 64 KiB of plaintext value input, the client rejects it before sending and does not log the raw value. +- Given a remote `get NAME --show`, only the explicit human CLI command response can include the value and JSON error/detail fields still redact secrets. +- Given a direct remote-control invocation lacks the human CLI reveal context, raw reveal is rejected even if the caller is otherwise authenticated. + +**Verification:** Remote tests prove operation ownership is target-side, authenticated self-hosted and hosted Cloud target selection is reused, and no raw value is written to local config or local Vault files. + +### U5. Runtime Diagnostics, Doctor, and Attach Preflight + +**Goal:** Surface Vault health, unresolved references, and missing grants through doctor, attach, and startup warnings before agent harnesses lose Caplets silently. + +**Requirements:** PR3, PR4, PR7, PR18 + +**Dependencies:** U1, U2, U4 + +**Files:** + +- `packages/core/src/cli/doctor.ts` +- `packages/core/src/attach/options.ts` +- `packages/core/src/attach/server.ts` +- `packages/core/src/native/service.ts` +- `packages/core/test/cli.test.ts` +- `packages/core/test/cli-remote.test.ts` +- `packages/core/test/native-service.test.ts` + +**Approach:** Extend config source warnings and doctor sections with Vault status. Doctor should scan local overlay config using the same resolver inputs as runtime loading, group ungranted, missing, unavailable, and invalid-key-source issues, and print repair commands with the correct target. Attach and native startup should surface those warnings as preflight diagnostics without prompting for values or grants. + +**Patterns to follow:** `tryLoadLocalOverlayForCli`, `formatDoctorReport`, `doctorJsonReport`, and the native service exposure diagnostics in `packages/core/src/cli/doctor.ts`. + +**Test scenarios:** + +- Covers AE3. Given a local missing Vault reference, doctor reports key, Caplet, target, and `caplets vault set` repair guidance without the value. +- Covers AE15. Given an existing local key without a grant, doctor reports the exact `caplets vault access grant` command. +- Covers AE15. Given an existing remote Vault key without a grant, doctor reports the exact `caplets vault access grant NAME --remote` command for the selected runtime target. +- Covers AE7. Given config later references a deleted Vault key, loading quarantines the affected Caplet as missing and status output explains any retained recovery state without revealing values. +- Covers AE8. Given a self-hosted remote or hosted Cloud target, diagnostics name that runtime target and use `--remote` guidance rather than local-only commands. +- Covers AE10. Given invalid key material, doctor reports key-source status and attach does not prompt for unlock input. +- Covers AE15. Given an agent-facing attach startup with ungranted Vault references, preflight output contains the Vault-specific reason before the Caplet is absent from the exposed surface. + +**Verification:** Doctor JSON stays machine-readable, plain output is actionable, and attach/native startup tests prove diagnostics happen before downstream tool discovery. + +### U6. Agent-Facing Exposure Boundary + +**Goal:** Ensure Code Mode, MCP progressive exposure, and direct tool surfaces cannot reveal raw Vault values. + +**Requirements:** PR11, PR17 + +**Dependencies:** U2, U3 + +**Files:** + +- `packages/core/src/code-mode/api.ts` +- `packages/core/src/tools.ts` +- `packages/core/src/registry.ts` +- `packages/core/test/code-mode-api.test.ts` +- `packages/core/test/tools.test.ts` +- `packages/core/test/registry.test.ts` + +**Approach:** Do not add Vault read operations to Caplet handles or wrapper tools. Add regression coverage that searches callable Code Mode and MCP/progressive operation surfaces for Vault reveal operations, and extend existing redaction tests so resolved backend details do not serialize raw Vault values into descriptions, observed output metadata, or registry detail responses. + +**Patterns to follow:** Existing registry tests that assert serialized Caplet detail does not contain secret env values, plus Code Mode callable-caplet tests. + +**Test scenarios:** + +- Covers AE6. Given a Code Mode session lists and inspects Caplet handles, no operation exists to read, reveal, or enumerate raw Vault values. +- Given progressive tools list/search/get operations include a Caplet configured with Vault auth, serialized descriptions and structured content omit the resolved value. +- Given an observed output shape or registry detail is generated after Vault resolution, the stored metadata does not include the raw Vault value. + +**Verification:** Agent-facing APIs continue to expose backend capability operations only, and raw Vault values appear only in explicit human CLI reveal tests. + +### U7. Catalog Migration and Guardrails + +**Goal:** Move built-in catalog secret-like setup from env vars to Vault and prevent regressions. + +**Requirements:** PR19, PR20 + +**Dependencies:** U2, U3 + +**Files:** + +- `caplets/github/CAPLET.md` +- `caplets/github/README.md` +- `packages/core/test/config.test.ts` +- `packages/core/test/catalog-vault.test.ts` + +**Approach:** Change the GitHub catalog token reference from `$env:GH_TOKEN` to `$vault:GH_TOKEN`, update setup docs to run `caplets vault set GH_TOKEN --grant github` for local/global use and `caplets vault set GH_TOKEN --remote --grant github` for self-hosted remote or hosted Cloud-backed use, and state that the command must target the runtime where the Caplet executes. Adjust repository catalog load tests to seed a test Vault store or resolver rather than mutating `process.env.GH_TOKEN`. Add a catalog-specific regression test that rejects secret-like `$env:` or bare `${NAME}` references under `caplets/` while ignoring benchmark harness env vars outside the catalog tree. + +**Patterns to follow:** Repository Caplet load tests in `packages/core/test/config.test.ts`. + +**Test scenarios:** + +- Covers AE11. Given repository catalog Caplets are loaded, GitHub uses `$vault:GH_TOKEN` and setup docs no longer instruct `export GH_TOKEN`. +- Covers AE11. Given GitHub setup docs are read by a remote or hosted Cloud user, they show the `--remote` Vault setup command and warn that Vault values must be written to the runtime where the Caplet executes. +- Covers AE12. Given OAuth-backed catalog Caplets such as Linear or Sourcegraph are loaded, their OAuth setup flow remains unchanged. +- Given a future catalog Caplet adds a secret-like `$env:` or bare `${NAME}` reference, the catalog guard test fails with the Caplet path and reference. +- Given benchmark live suites still require env vars for benchmark harness execution, the catalog guard does not flag benchmark-only env usage outside `caplets/`. + +**Verification:** Catalog tests prove all checked-in Caplets remain loadable with Vault fixtures, docs reference the new Vault setup flow, and benchmark checks remain deterministic. + +### U8. Public Docs, Schema, and Release Notes + +**Goal:** Document Vault setup, remote targeting, grants, diagnostics, and scope boundaries for users and implementers. + +**Requirements:** PR4, PR9, PR10, PR11, PR12, PR16, PR18, PR20 + +**Dependencies:** U1, U2, U3, U4, U5, U7 + +**Files:** + +- `README.md` +- `docs/product/` +- `docs/adr/` +- `docs/agents/domain.md` +- `CONCEPTS.md` +- `CONTEXT.md` +- `packages/core/src/config.ts` +- `packages/core/test/config.test.ts` +- `.changeset/` + +**Approach:** Add user docs for local/global Vault, self-hosted remote Vault, hosted Cloud target semantics, local and remote access grants, `--as` mapping, explicit reveal, delete behavior, and quarantine recovery. Add an ADR only if implementation settles a durable encryption-store record format or key-source compatibility contract beyond the requirements doc. Regenerate config schema only if schema descriptions need to mention Vault interpolation. + +**Patterns to follow:** Remote Login docs language from the unified remote attach work and glossary style in `CONCEPTS.md`. + +**Test scenarios:** + +- Documentation expectation: docs include local, self-hosted remote, and hosted Cloud setup commands, local and `--remote` grant commands, explicit reveal warnings, and no project Vault flow. +- Covers AE8. Given docs describe self-hosted remote or hosted Cloud setup, they name the active target and use `--remote` where appropriate. +- Schema expectation: if schema descriptions change, generated schema check passes and includes Vault interpolation wording without changing field shapes. + +**Verification:** Docs and generated references are aligned with implemented CLI syntax, and glossary/context entries still match the final feature name and semantics. + +--- + +## System-Wide Impact + +- **Config loading:** `parseConfig`, isolated config loading, local overlay loading, Caplet file loading, and source warning formatting all gain a Vault reference class through an explicit synchronous resolver context. +- **Runtime availability:** More Caplets may quarantine by default until users grant access; diagnostics must make that fail-closed behavior feel like setup, not random disappearance. +- **Remote runtime:** Remote management depends on authenticated self-hosted remote control or the hosted Cloud client contract and must execute on the selected runtime target to preserve local/remote separation. +- **Security posture:** Vault adds encrypted-at-rest local state and a new explicit reveal path, so redaction tests need to cover CLI, remote errors, registry serialization, and agent-facing APIs. +- **Catalog onboarding:** GitHub setup changes from shell env export to Vault set plus grant. Users updating catalog Caplets need to run the new setup once. + +--- + +## Risks & Dependencies + +- **Cryptographic misuse risk:** Use the pinned AES-256-GCM envelope, 32-byte key contract, 96-bit random nonces, explicit auth tags, and versioned JSON records rather than ad hoc reversible encoding. Keep crypto helpers isolated and test tamper failures. +- **False sense of security from local key file:** Local v1 protects against accidental plaintext-on-disk exposure, not against a fully compromised user account. Docs should state that `CAPLETS_ENCRYPTION_KEY` and the minted key file are runtime key material. +- **Grant ergonomics risk:** Required grants could make setup feel heavy. `set --grant`, doctor repair commands, and catalog setup docs are mandatory mitigation. +- **Remote leakage risk:** Remote-control errors, success diagnostics, proxy logs, and reveal responses may accidentally include raw values. Tests should force secret-bearing success and failure paths through value-aware dispatcher, client, and Cloud-client redaction. +- **Cloud boundary risk:** Hosted Cloud Vault server/runtime implementation is outside this repository. This plan covers the CLI/client contract, request shape, target selection, diagnostics, and local no-mirroring behavior that the Cloud implementation must satisfy. +- **Catalog regression risk:** The guardrail needs to detect secret-like catalog env refs without blocking legitimate nonsecret benchmark env use. +- **Changeset dependency:** This is user-facing CLI and package behavior, so implementation should include an appropriate changeset unless the release process intentionally labels the PR otherwise. + +--- + +## Scope Boundaries + +### In Scope + +- Built-in Caplets encrypted Vault provider. +- Local/global and remote targets only. +- String values only. +- 64 KiB plaintext maximum per Vault value. +- `$vault:NAME` and `${vault:NAME}` interpolation. +- Explicit human CLI reveal. +- Access grants with `--as` remapping. +- GitHub catalog migration and catalog guardrails. + +### Deferred to Follow-Up Work + +- OS keychain-backed key-source implementation. +- External provider integrations such as AWS Secrets Manager or HashiCorp Vault. +- Rotation workflows beyond setting a new value with `--force`. +- Migration helpers for already-installed user catalog Caplets. + +### Outside This Product Scope + +- Project-scoped Vault stores. +- Committed encrypted `.caplets` Vault files. +- Provider-qualified syntax such as `$vault:aws/NAME`. +- Structured or JSON Vault values. +- Agent-facing APIs that reveal raw Vault values. +- Replacing existing Caplets-owned OAuth flows with Vault. + +--- + +## Sources & Research + +- `docs/brainstorms/2026-06-22-caplets-vault-requirements.md` is the origin requirements source. +- `packages/core/src/config.ts` contains the existing env interpolation, public metadata exclusion, and missing-env quarantine path to extend. +- `packages/core/test/config.test.ts` contains local overlay quarantine and catalog load coverage. +- `packages/core/src/remote/credential-store.ts` and `packages/core/src/cloud-auth/store.ts` provide adjacent Caplets-owned credential storage and redacted status patterns. +- `packages/core/src/remote-control/dispatch.ts` and `packages/core/src/remote-control/client.ts` provide the remote CLI ownership and redaction pattern for `--remote` Vault management. +- `docs/solutions/integration-issues/stale-remote-profile-credentials-refresh.md` records why Caplets-owned credential state should replace env-secret propagation in long-lived agent runtime paths. +- Node.js `node:crypto` documentation describes the stable cryptographic primitives available to the Node runtime: https://nodejs.org/api/crypto.html +- Node.js `node:fs` documentation defines permission mode behavior used by owner-only key and store files: https://nodejs.org/api/fs.html +- OWASP Secrets Management and Key Management cheat sheets inform the key/value separation, encrypted-at-rest expectation, and no-logging posture: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html and https://cheatsheetseries.owasp.org/cheatsheets/Key_Management_Cheat_Sheet.html + +--- + +## Acceptance Trace + +- AE1: U1, U2 +- AE2: U2 +- AE3: U2, U5 +- AE4: U3 +- AE5: U4 +- AE6: U6 +- AE7: U1, U3, U5 +- AE8: U5, U8 +- AE9: U4 +- AE10: U1, U5 +- AE11: U7 +- AE12: U7 +- AE13: U2 +- AE14: U1, U3, U4 +- AE15: U5 +- AE16: U1, U2, U3 diff --git a/docs/product/caplets-vault.md b/docs/product/caplets-vault.md new file mode 100644 index 00000000..59df29a6 --- /dev/null +++ b/docs/product/caplets-vault.md @@ -0,0 +1,71 @@ +# Caplets Vault + +Caplets Vault is a runtime-owned encrypted string store for secret-like config values. It replaces +fragile process environment propagation when an agent harness does not pass environment variables to +the Caplets runtime. + +## Config Syntax + +Use Vault references anywhere normal config interpolation applies: + +```json +{ + "mcpServers": { + "github": { + "name": "GitHub", + "description": "GitHub tools", + "transport": "http", + "url": "https://api.githubcopilot.com/mcp", + "auth": { "type": "bearer", "token": "$vault:GH_TOKEN" } + } + } +} +``` + +Both `$vault:NAME` and `${vault:NAME}` are supported. Public metadata fields such as `name`, +`description`, `tags`, and Markdown body text preserve the literal reference text. + +## Local Vault + +Local/global is the default target: + +```sh +caplets vault set GH_TOKEN +caplets vault access grant GH_TOKEN github +``` + +For common one-key setup, set and grant atomically: + +```sh +caplets vault set GH_TOKEN --grant github +``` + +`caplets vault get NAME` prints metadata only. Use `--show` for an explicit human reveal. + +## Remote Vault + +Use `--remote` when the Caplet executes in a self-hosted remote runtime or hosted Cloud runtime: + +```sh +caplets vault set GH_TOKEN --remote --grant github +caplets vault access grant GH_TOKEN github --remote +``` + +Remote Vault values are owned by the selected runtime. Local Caplets do not read, mirror, or forward +remote or Cloud Vault values. + +## Grants And Remapping + +Vault resolution requires an access grant for the stored key, Caplet ID, referenced key name, and +config origin. Use `--as` when the stored key and config reference differ: + +```sh +caplets vault access grant GH_TOKEN_PERSONAL github-personal --as GH_TOKEN +caplets vault access grant GH_TOKEN_WORK github-work --as GH_TOKEN +``` + +## Diagnostics + +Unset, unavailable, invalid, or ungranted Vault references quarantine only the affected Caplet. +`caplets doctor` reports the key, Caplet, target, config path, and repair command without printing +raw values. diff --git a/docs/solutions/integration-issues/vault-cli-runtime-integration-fixes.md b/docs/solutions/integration-issues/vault-cli-runtime-integration-fixes.md new file mode 100644 index 00000000..465ed372 --- /dev/null +++ b/docs/solutions/integration-issues/vault-cli-runtime-integration-fixes.md @@ -0,0 +1,151 @@ +--- +title: Vault CLI, Runtime, and Remote Paths Handle $vault Refs Consistently +date: 2026-06-22 +category: integration-issues +module: Vault +problem_type: integration_issue +component: tooling +symptoms: + - "self-hosted remote control could forge raw Vault reveal output" + - "best-effort config validation rejected granted $vault URL fields" + - "caplets vault set rejected real piped stdin input" + - "grant remaps kept stale stored-key mappings" + - "failed forced set-and-grant flows could leave mutated Vault values" +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: + - vault + - cli + - remote-control + - runtime-loader + - config-validation + - quarantine + - grants +--- + +# Vault CLI, Runtime, and Remote Paths Handle $vault Refs Consistently + +## Problem + +Caplets Vault added encrypted string values that config can reference with `$vault:NAME` or `${vault:NAME}`. The feature worked at the direct CLI path, but review found that adjacent execution surfaces did not all enforce the same Vault rules. + +The most serious symptom was raw reveal: self-hosted remote control accepted caller-provided `revealContext=human-cli` request data, so a generic authenticated remote-control caller could ask for a raw Vault value. Other symptoms were less severe but came from the same boundary problem: validation could reject valid `$vault:` fields, runtime paths could execute with unresolved Vault references, grant remaps could keep stale stored keys, and failed set-and-grant flows could leave partially mutated Vault state. + +Relevant code paths: + +- `packages/core/src/remote-control/dispatch.ts` +- `packages/core/src/config.ts` +- `packages/core/src/cli.ts` +- `packages/core/src/vault/access.ts` +- `packages/core/src/engine.ts` +- `packages/core/src/cloud/client.ts` + +## Symptoms + +- `vault_get reveal=true` could be exercised through generic self-hosted remote control instead of only an explicit human reveal path. +- Best-effort config validation quarantined Caplets that were valid once resolved with the same Vault context used at runtime. +- `caplets vault set` advertised stdin support, but real non-TTY stdin was rejected unless tests injected a reader. +- Forced set-and-grant flows could overwrite an existing Vault value and then fail to create the matching grant. +- Access grants were not fully identified by `capletId + referenceName + origin`, so remapping or moving config could collide with stale identity. +- Engine startup and reload could quarantine Vault-backed Caplets without surfacing useful diagnostics to the operator or harness. + +## What Didn't Work + +Trusting a caller-supplied "human CLI" reveal marker did not hold up. It was request data, not proof that the request came from an interactive human surface. The self-hosted remote dispatcher needed to reject raw reveal directly rather than forwarding that decision to the request payload. + +Keeping parse-level config loading pure was still the right design, but relying on pure `loadConfig` or `loadConfigWithSources` in runtime-adjacent paths caused broken behavior. Constrained fields such as URLs could be validated before Vault interpolation, and direct engine execution could receive literal `$vault:` references instead of resolved values or quarantine warnings. + +The initial grant model also let stale mappings survive remaps. If grant identity includes the stored key, then granting `GH_TOKEN_PERSONAL` to a Caplet reference and later remapping that same reference to another stored key can create a second grant instead of replacing the first. + +On the write path, treating `vault set --grant` as two loosely related writes left a bad failure mode. Rollback for newly created values was not enough; forced updates also need to restore the previous encrypted value if grant creation fails. + +## Solution + +Treat Vault as runtime-owned state and make each execution boundary apply the same policy. + +Raw reveal is blocked in the self-hosted remote dispatcher. Cloud still sends an explicit `revealContext=human-cli` in the client request shape so the Cloud implementation can make the same server-side distinction, but the Cloud server implementation is outside this repository. + +```ts +if (request.command === "vault_get" && reveal) { + throw new CapletsError( + "REQUEST_INVALID", + "Self-hosted remote Vault reveal is not supported through remote control.", + ); +} +``` + +Runtime config loading now uses the Vault-aware loader. `CapletsEngine` defaults to `loadLocalRuntimeConfig`, and startup/reload warnings flow through `writeErr` so local `serve`, native, and direct CLI engine paths do not silently drop Vault-backed Caplets. + +Best-effort validation now receives the same Vault resolver and source metadata as runtime interpolation. That lets fields validate after applying the actual `$vault:` resolution context, including the config origin needed for grant checks. + +Grant identity is now the reference identity, not the stored secret identity: + +```ts +function sameGrantIdentity(left: VaultAccessGrant, right: VaultAccessGrant): boolean { + return ( + left.referenceName === right.referenceName && + left.capletId === right.capletId && + sameOrigin(left.origin, right.origin) + ); +} +``` + +That lets a remap update `storedKey` for the same Caplet reference instead of accumulating competing grants. + +`vault set --grant` is transactional from the user's point of view. If grant creation fails, the value write is rolled back whether it created a new value or forced an update over an existing one. + +```ts +try { + if (grantInput) store.grantAccess(grantInput); +} catch (error) { + if (existed && previousValue !== undefined) { + store.set(name, previousValue, { force: true }); + } else { + store.delete(name); + } + throw error; +} +``` + +The CLI also reads real piped stdin when `--value` is omitted, strips one trailing newline, rejects empty input, returns JSON for remote `vault set --json`, and formats Cloud access lists even when the Cloud response omits `origin`. + +## Why This Works + +The underlying issue was not one bad branch. It was inconsistent policy at the boundaries where Vault touched config, CLI, remote control, Cloud, and runtime startup. + +The fix makes the answer to each boundary question explicit: + +- Raw reveal is a privileged human operation, so generic self-hosted remote control rejects it. +- Runtime execution uses Vault-aware loading, so execution surfaces either receive resolved config or recoverable quarantine diagnostics. +- Validation uses the same resolver and source metadata as runtime interpolation, so validation and execution no longer disagree about valid `$vault:` references. +- Grant identity follows the Caplet reference and config origin, so remapping a reference replaces the grant target instead of leaving stale stored-key authority. +- Set-and-grant behaves atomically, so setup commands do not leave misleading partial state after a grant failure. + +This also matches the older Remote Profile lesson: auth-bearing or secret-bearing values should be resolved as runtime-owned state, not copied into long-lived process startup state or agent config. + +## Prevention + +- Treat raw secret reveal as a separate capability. Generic transport and remote-control layers should reject it unless they can prove the caller is the intended human surface. +- Use the Vault-aware runtime loader for execution surfaces. Keep parse-level helpers pure, but do not use pure config loaders as runtime entry points. +- Pass source metadata through interpolation and validation whenever access decisions depend on config origin. +- Key Vault grants by Caplet reference identity and origin, then store the mapped Vault key as mutable grant data. +- Make combined write-and-grant flows rollback-safe for both create and forced-update cases. +- Emit recoverable quarantine warnings at startup and reload, not only in `caplets doctor`. + +Regression tests should cover: + +- forged self-hosted remote `vault_get reveal=true` +- `$vault:` interpolation in constrained config fields such as URLs +- real piped stdin for `caplets vault set` +- grant remap replacement +- rollback for failed set-and-grant creates and forced updates +- runtime startup warnings for Vault quarantine +- Cloud client reveal request shape and access-list formatting + +## Related Issues + +- Related high-overlap doc: [Native Remote Clients Refresh Runtime Credentials Before Polls and Reconnects](./stale-remote-profile-credentials-refresh.md). It covers the same runtime-owned-state rule for Remote Profile credentials. +- Related moderate-overlap doc: [Code Mode REPL Sessions Use Live State Plus Recovery Journals](../architecture-patterns/code-mode-repl-sessions.md). It covers agent-facing runtime state and redaction boundaries. +- Related moderate-overlap doc: [Native Daemon Management Belongs Behind an Install-Time Service Contract](../architecture-patterns/native-daemon-service-management.md). It covers service lifecycle ownership, but not Vault quarantine diagnostics. +- GitHub issue search found no matching open or closed issues for this Vault review cluster. diff --git a/packages/core/src/caplet-files-bundle.ts b/packages/core/src/caplet-files-bundle.ts index dd268859..c9b9ee40 100644 --- a/packages/core/src/caplet-files-bundle.ts +++ b/packages/core/src/caplet-files-bundle.ts @@ -242,7 +242,7 @@ const capletMcpServerSchema = z }); } - if (server.url && !hasEnvReference(server.url) && !isAllowedRemoteUrl(server.url)) { + if (server.url && !hasInterpolationReference(server.url) && !isAllowedRemoteUrl(server.url)) { ctx.addIssue({ code: "custom", path: ["url"], @@ -259,7 +259,7 @@ const capletMcpServerSchema = z "redirectUri", ] as const) { const value = server.auth[field]; - if (value && !hasEnvReference(value) && !isUrl(value)) { + if (value && !hasInterpolationReference(value) && !isUrl(value)) { ctx.addIssue({ code: "custom", path: ["auth", field], @@ -319,7 +319,7 @@ const capletOpenApiEndpointSchema = z } if ( endpoint.specUrl && - !hasEnvReference(endpoint.specUrl) && + !hasInterpolationReference(endpoint.specUrl) && !isAllowedRemoteUrl(endpoint.specUrl) ) { ctx.addIssue({ @@ -330,7 +330,7 @@ const capletOpenApiEndpointSchema = z } if ( endpoint.baseUrl && - !hasEnvReference(endpoint.baseUrl) && + !hasInterpolationReference(endpoint.baseUrl) && !isAllowedRemoteUrl(endpoint.baseUrl) ) { ctx.addIssue({ @@ -383,7 +383,7 @@ const capletGoogleDiscoveryApiSchema = z } if ( api.discoveryUrl && - !hasEnvReference(api.discoveryUrl) && + !hasInterpolationReference(api.discoveryUrl) && !isAllowedRemoteUrl(api.discoveryUrl) ) { ctx.addIssue({ @@ -392,7 +392,11 @@ const capletGoogleDiscoveryApiSchema = z message: "Google Discovery discoveryUrl must use https except loopback development urls", }); } - if (api.baseUrl && !hasEnvReference(api.baseUrl) && !isAllowedHttpBaseUrl(api.baseUrl)) { + if ( + api.baseUrl && + !hasInterpolationReference(api.baseUrl) && + !isAllowedHttpBaseUrl(api.baseUrl) + ) { ctx.addIssue({ code: "custom", path: ["baseUrl"], @@ -478,7 +482,7 @@ const capletGraphQlEndpointSchema = z } if ( endpoint.endpointUrl && - !hasEnvReference(endpoint.endpointUrl) && + !hasInterpolationReference(endpoint.endpointUrl) && !isAllowedRemoteUrl(endpoint.endpointUrl) ) { ctx.addIssue({ @@ -489,7 +493,7 @@ const capletGraphQlEndpointSchema = z } if ( endpoint.schemaUrl && - !hasEnvReference(endpoint.schemaUrl) && + !hasInterpolationReference(endpoint.schemaUrl) && !isAllowedRemoteUrl(endpoint.schemaUrl) ) { ctx.addIssue({ @@ -577,7 +581,11 @@ const capletHttpApiSchema = z }) .strict() .superRefine((api, ctx) => { - if (api.baseUrl && !hasEnvReference(api.baseUrl) && !isAllowedHttpBaseUrl(api.baseUrl)) { + if ( + api.baseUrl && + !hasInterpolationReference(api.baseUrl) && + !isAllowedHttpBaseUrl(api.baseUrl) + ) { ctx.addIssue({ code: "custom", path: ["baseUrl"], @@ -1116,7 +1124,7 @@ export function normalizeBundleLocalPath( value: string | undefined, baseDir: string, ): string | undefined { - if (!value || isMapAbsolutePath(value) || hasEnvReference(value)) { + if (!value || isMapAbsolutePath(value) || hasInterpolationReference(value)) { return value; } const parts = [...(baseDir ? baseDir.split("/") : []), ...value.split("/")]; @@ -1242,8 +1250,10 @@ export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function hasEnvReference(value: string): boolean { - return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*/.test(value); +function hasInterpolationReference(value: string): boolean { + return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*|\$\{vault:[^}]+\}|\$vault:[A-Za-z0-9_-]+/.test( + value, + ); } function patchCapletJsonSchema(schema: T): T { diff --git a/packages/core/src/caplet-files.ts b/packages/core/src/caplet-files.ts index 1652c805..0b08934b 100644 --- a/packages/core/src/caplet-files.ts +++ b/packages/core/src/caplet-files.ts @@ -182,12 +182,14 @@ export function validateCapletFile(path: string): void { } function normalizeLocalPath(value: string | undefined, baseDir: string): string | undefined { - if (!value || isAbsolute(value) || hasEnvReference(value)) { + if (!value || isAbsolute(value) || hasInterpolationReference(value)) { return value; } return join(baseDir, value); } -function hasEnvReference(value: string): boolean { - return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*/.test(value); +function hasInterpolationReference(value: string): boolean { + return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*|\$\{vault:[^}]+\}|\$vault:[A-Za-z0-9_-]+/.test( + value, + ); } diff --git a/packages/core/src/caplet-sets.ts b/packages/core/src/caplet-sets.ts index 34e97342..5fc81202 100644 --- a/packages/core/src/caplet-sets.ts +++ b/packages/core/src/caplet-sets.ts @@ -1,7 +1,12 @@ import type { CompatibilityCallToolResult, Tool } from "@modelcontextprotocol/sdk/types"; import { resolve } from "node:path"; import { CliToolsManager } from "./cli-tools"; -import { type CapletConfig, type CapletSetConfig, loadIsolatedConfig } from "./config"; +import { + type CapletConfig, + type CapletSetConfig, + loadIsolatedConfig, + vaultResolverForAuthDir, +} from "./config"; import { compactToolSafetyHints, compactToolSchemaHints, @@ -218,6 +223,7 @@ export class CapletSetManager { ...(config.capletsRoot ? { capletsRoot: config.capletsRoot } : {}), defaultSearchLimit: config.defaultSearchLimit, maxSearchLimit: config.maxSearchLimit, + vaultResolver: vaultResolverForAuthDir(this.options.authDir), }); const registry = new ServerRegistry(childConfig); const sharedOptions = { diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 23142be6..bee635e6 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -37,12 +37,21 @@ import { } from "./cli/completion"; import { CloudAuthClient } from "./cloud-auth/client"; import { openBrowserUrl } from "./cloud-auth/open-url"; +import { CapletsCloudClient } from "./cloud/client"; import { formatCapletList, formatConfigPaths, listCaplets, resolveCliConfigPaths, } from "./cli/inspection"; +import { + formatVaultAccessGrant, + formatVaultAccessList, + formatVaultAccessRevoke, + formatVaultDeleteStatus, + formatVaultValueList, + formatVaultValueStatus, +} from "./cli/vault"; import { installCaplets } from "./cli/install"; import { formatSetupMenu, @@ -63,6 +72,7 @@ import { resolveConfigPath, resolveProjectCapletsRoot, resolveProjectConfigPath, + vaultBootstrapResolver, } from "./config"; import { CapletsEngine } from "./engine"; import { CapletsError } from "./errors"; @@ -105,6 +115,8 @@ import { import { resolveServeOptions, serveResolvedCaplets, type ServeOptions } from "./serve"; import { DEFAULT_AUTH_DIR } from "./config/paths"; import { appendBasePath } from "./server/options"; +import { FileVaultStore, VAULT_MAX_VALUE_BYTES, validateVaultKeyName } from "./vault"; +import type { VaultAccessGrantFilter } from "./vault"; export { initConfig, starterConfig } from "./cli/init"; export { installCaplets, normalizeGitRepo } from "./cli/install"; @@ -1623,6 +1635,299 @@ export function createProgram(io: CliIO = {}): Command { writeOut(await formatDoctorReport({ env, ...(io.daemon ? { daemon: io.daemon } : {}) })); }); + const vault = program.command(cliCommands.vault).description("Manage Caplets Vault values."); + + vault + .command("set") + .description("Set a local/global Vault value.") + .argument("", "Vault key name") + .option("-g, --global", "target the local/global Vault") + .option("--remote", "target the selected remote Vault") + .option("--force", "overwrite an existing Vault value") + .option("--grant ", "grant this key to a configured Caplet after setting it") + .option("--as ", "reference name the Caplet uses in config") + .option("--json", "print JSON output") + .action( + async ( + name: string, + options: VaultTargetOptions & { + force?: boolean; + grant?: string; + as?: string; + json?: boolean; + }, + ) => { + const target = parseVaultTarget(options); + if (target === "remote") { + const value = await readVaultValue(io); + assertVaultTransportValueSize(value); + const status = await remoteVaultSet(io, { + name, + value, + force: Boolean(options.force), + ...(options.grant ? { grant: options.grant } : {}), + ...((options.as ?? options.grant) ? { referenceName: options.as ?? name } : {}), + }); + if (options.json) { + writeOut(`${JSON.stringify(status, null, 2)}\n`); + return; + } + writeOut(`Set remote Vault key ${validateVaultKeyName(name)}.\n`); + if (options.grant) { + writeOut( + `Granted remote Vault key ${validateVaultKeyName(name)} to ${options.grant} as ${validateVaultKeyName(options.as ?? name)}.\n`, + ); + } + return; + } + const value = await readVaultValue(io); + const store = new FileVaultStore({ env }); + const existed = store.getStatus(name).present; + const previousValue = existed && options.grant ? store.resolveValue(name) : undefined; + const status = store.set(name, value, { force: Boolean(options.force) }); + try { + if (options.grant) { + const origin = resolveVaultAccessOrigin(options.grant, io); + store.grantAccess({ + storedKey: name, + referenceName: options.as ?? name, + capletId: options.grant, + origin, + }); + } + } catch (error) { + if (existed && previousValue !== undefined) { + store.set(name, previousValue, { force: true }); + } else { + store.delete(name); + } + throw error; + } + if (options.json) { + writeOut(`${JSON.stringify(status, null, 2)}\n`); + return; + } + writeOut(`Set Vault key ${validateVaultKeyName(name)}.\n`); + if (options.grant) { + writeOut( + `Granted Vault key ${validateVaultKeyName(name)} to ${options.grant} as ${validateVaultKeyName(options.as ?? name)}.\n`, + ); + } + }, + ); + + vault + .command("get") + .description("Show local/global Vault metadata, or reveal with --show.") + .argument("", "Vault key name") + .option("-g, --global", "target the local/global Vault") + .option("--remote", "target the selected remote Vault") + .option("--show", "reveal the raw Vault value") + .option("--json", "print JSON output") + .action( + async (name: string, options: VaultTargetOptions & { show?: boolean; json?: boolean }) => { + const target = parseVaultTarget(options); + if (target === "remote") { + const result = await remoteVaultGet(io, { name, reveal: Boolean(options.show) }); + if (options.show) { + const value = + result && typeof result === "object" && "value" in result + ? String((result as { value: unknown }).value) + : ""; + writeOut(options.json ? `${JSON.stringify(result, null, 2)}\n` : `${value}\n`); + return; + } + writeOut( + formatVaultValueStatus( + result as ReturnType, + Boolean(options.json), + ), + ); + return; + } + const store = new FileVaultStore({ env }); + if (options.show) { + const value = store.resolveValue(name); + writeOut( + options.json ? `${JSON.stringify({ key: name, value }, null, 2)}\n` : `${value}\n`, + ); + return; + } + writeOut(formatVaultValueStatus(store.getStatus(name), Boolean(options.json))); + }, + ); + + vault + .command("list") + .description("List local/global Vault keys without revealing values.") + .option("-g, --global", "target the local/global Vault") + .option("--remote", "target the selected remote Vault") + .option("--json", "print JSON output") + .action(async (options: VaultTargetOptions & { json?: boolean }) => { + const target = parseVaultTarget(options); + if (target === "remote") { + const result = await remoteVaultList(io); + writeOut( + formatVaultValueList( + result as ReturnType, + Boolean(options.json), + ), + ); + return; + } + writeOut( + formatVaultValueList(new FileVaultStore({ env }).listValues(), Boolean(options.json)), + ); + }); + + vault + .command("delete") + .description("Delete a local/global Vault value without revealing it.") + .argument("", "Vault key name") + .option("-g, --global", "target the local/global Vault") + .option("--remote", "target the selected remote Vault") + .option("--json", "print JSON output") + .action(async (name: string, options: VaultTargetOptions & { json?: boolean }) => { + const target = parseVaultTarget(options); + if (target === "remote") { + const result = await remoteVaultDelete(io, name); + writeOut( + formatVaultDeleteStatus( + result as ReturnType, + Boolean(options.json), + ), + ); + return; + } + writeOut( + formatVaultDeleteStatus(new FileVaultStore({ env }).delete(name), Boolean(options.json)), + ); + }); + + const vaultAccess = vault.command("access").description("Manage Vault access grants."); + + vaultAccess + .command("grant") + .description("Grant a Vault key to a configured Caplet.") + .argument("", "stored Vault key name") + .argument("", "configured Caplet ID") + .option("-g, --global", "target the local/global Vault") + .option("--remote", "target the selected remote Vault") + .option("--as ", "reference name the Caplet uses in config") + .option("--json", "print JSON output") + .action( + async ( + name: string, + capletId: string, + options: VaultTargetOptions & { as?: string; json?: boolean }, + ) => { + const target = parseVaultTarget(options); + if (target === "remote") { + const grant = await remoteVaultAccessGrant(io, { + name, + capletId, + referenceName: options.as ?? name, + }); + writeOut( + formatVaultAccessGrant( + grant as ReturnType, + Boolean(options.json), + ), + ); + return; + } + const origin = resolveVaultAccessOrigin(capletId, io); + const grant = new FileVaultStore({ env }).grantAccess({ + storedKey: name, + referenceName: options.as ?? name, + capletId, + origin, + }); + writeOut(formatVaultAccessGrant(grant, Boolean(options.json))); + }, + ); + + vaultAccess + .command("list") + .description("List Vault access grants without revealing values.") + .argument("[name]", "optional stored Vault key name") + .argument("[capletId]", "optional configured Caplet ID") + .option("-g, --global", "target the local/global Vault") + .option("--remote", "target the selected remote Vault") + .option("--caplet ", "filter by configured Caplet ID") + .option("--json", "print JSON output") + .action( + async ( + name: string | undefined, + capletId: string | undefined, + options: VaultTargetOptions & { caplet?: string; json?: boolean }, + ) => { + if (options.caplet && capletId && options.caplet !== capletId) { + throw new CapletsError( + "REQUEST_INVALID", + "Use either positional capletId or --caplet, not both.", + ); + } + const capletFilter = options.caplet ?? capletId; + const target = parseVaultTarget(options); + if (target === "remote") { + const grants = await remoteVaultAccessList(io, { + ...(name ? { name } : {}), + ...(capletFilter ? { capletId: capletFilter } : {}), + }); + writeOut( + formatVaultAccessList( + grants as ReturnType, + Boolean(options.json), + ), + ); + return; + } + writeOut( + formatVaultAccessList( + new FileVaultStore({ env }).listAccess(vaultAccessFilter(name, capletFilter)), + Boolean(options.json), + ), + ); + }, + ); + + vaultAccess + .command("revoke") + .description("Revoke Vault access grants.") + .argument("", "stored Vault key name") + .argument("", "configured Caplet ID") + .option("-g, --global", "target the local/global Vault") + .option("--remote", "target the selected remote Vault") + .option("--as ", "reference name the Caplet uses in config") + .option("--json", "print JSON output") + .action( + async ( + name: string, + capletId: string, + options: VaultTargetOptions & { as?: string; json?: boolean }, + ) => { + const target = parseVaultTarget(options); + if (target === "remote") { + const revoked = await remoteVaultAccessRevoke(io, { + name, + capletId, + ...(options.as ? { referenceName: options.as } : {}), + }); + writeOut( + formatVaultAccessRevoke( + Array.isArray(revoked) ? revoked.length : 0, + Boolean(options.json), + ), + ); + return; + } + const filter = vaultAccessFilter(name, capletId, options.as); + const revoked = new FileVaultStore({ env }).revokeAccess(filter); + writeOut(formatVaultAccessRevoke(revoked.length, Boolean(options.json))); + }, + ); + program .command(cliCommands.list) .description("List configured Caplets.") @@ -1646,7 +1951,9 @@ export function createProgram(io: CliIO = {}): Command { writeOut(formatCapletList(rows, options.format ?? "plain")); return; } - const config = loadConfigWithSources(currentConfigPath(), envProjectConfigPath(env)); + const config = loadConfigWithSources(currentConfigPath(), envProjectConfigPath(env), { + vaultResolver: vaultBootstrapResolver, + }); const rows = listCaplets(config, { includeDisabled }); if (options.json || options.format === "json") { writeOut(`${JSON.stringify(rows, null, 2)}\n`); @@ -2402,6 +2709,7 @@ export function createProgram(io: CliIO = {}): Command { ...(projectConfigPath ? { projectConfigPath } : {}), config: localAuthConfigForTarget({ serverId, + ...(io.authDir ? { authDir: io.authDir } : {}), ...(configPath ? { configPath } : {}), ...(projectConfigPath ? { projectConfigPath } : {}), source: target, @@ -2438,6 +2746,7 @@ export function createProgram(io: CliIO = {}): Command { ...(configPath ? { configPath } : {}), config: localAuthConfigForTarget({ serverId, + ...(io.authDir ? { authDir: io.authDir } : {}), ...(configPath ? { configPath } : {}), ...(projectConfigPath ? { projectConfigPath } : {}), source: target, @@ -2468,6 +2777,7 @@ export function createProgram(io: CliIO = {}): Command { ...(configPath ? { configPath } : {}), config: localAuthConfigForTarget({ serverId, + ...(io.authDir ? { authDir: io.authDir } : {}), ...(configPath ? { configPath } : {}), ...(projectConfigPath ? { projectConfigPath } : {}), source: target, @@ -2600,6 +2910,17 @@ type MutationTargetOptions = { type AuthTargetOptions = MutationTargetOptions; +type VaultTarget = "global" | "remote"; + +type VaultTargetOptions = { + global?: boolean; + remote?: boolean; +}; + +type VaultRemoteTarget = + | { kind: "self_hosted"; client: RemoteControlClient } + | { kind: "cloud"; client: CapletsCloudClient; workspace: string }; + type AddCliResult = { path?: string; text: string; remote?: boolean }; function remoteAddOptions>( @@ -2642,6 +2963,204 @@ function parseMutationTarget(options: MutationTargetOptions): MutationTarget { return "project"; } +function parseVaultTarget(options: VaultTargetOptions): VaultTarget { + const selected = [ + options.global ? "--global" : undefined, + options.remote ? "--remote" : undefined, + ].filter((value): value is string => value !== undefined); + if (selected.length > 1) { + throw new CapletsError( + "REQUEST_INVALID", + `Cannot combine Vault target flags: ${selected.join(", ")}`, + ); + } + if (options.remote) return "remote"; + return "global"; +} + +async function resolveVaultRemoteTarget(io: CliIO): Promise { + const env = io.env ?? process.env; + const mode = resolveRemoteMode({}, env).mode; + if (mode === "remote") { + return { kind: "self_hosted", client: requireRemoteClientForTarget(io) }; + } + if (mode !== "cloud") { + throw new CapletsError( + "REQUEST_INVALID", + "--remote requires CAPLETS_MODE=remote or CAPLETS_MODE=cloud and CAPLETS_REMOTE_URL", + ); + } + const selection = await resolveRemoteSelection( + { + mode: "cloud", + ...(io.authDir ? { authDir: io.authDir } : {}), + ...(io.fetch ? { fetch: io.fetch } : {}), + }, + env, + ); + if (selection.kind !== "hosted_cloud") { + throw new CapletsError("REQUEST_INVALID", "--remote Vault target did not resolve to Cloud."); + } + return { + kind: "cloud", + workspace: selection.selectedWorkspace, + client: new CapletsCloudClient({ + baseUrl: selection.remote.baseUrl, + accessToken: selection.credentials.accessToken, + ...(selection.remote.fetch ? { fetch: selection.remote.fetch } : {}), + }), + }; +} + +async function remoteVaultSet( + io: CliIO, + input: { + name: string; + value: string; + force: boolean; + grant?: string | undefined; + referenceName?: string | undefined; + }, +): Promise { + const target = await resolveVaultRemoteTarget(io); + if (target.kind === "self_hosted") return await target.client.request("vault_set", input); + return await target.client.setVaultValue({ workspace: target.workspace, ...input }); +} + +async function remoteVaultGet( + io: CliIO, + input: { name: string; reveal: boolean }, +): Promise { + const target = await resolveVaultRemoteTarget(io); + if (target.kind === "self_hosted") { + return await target.client.request("vault_get", { + name: input.name, + reveal: input.reveal, + }); + } + return await target.client.getVaultValue({ + workspace: target.workspace, + name: input.name, + reveal: input.reveal, + }); +} + +async function remoteVaultList(io: CliIO): Promise { + const target = await resolveVaultRemoteTarget(io); + if (target.kind === "self_hosted") return await target.client.request("vault_list", {}); + return await target.client.listVaultValues({ workspace: target.workspace }); +} + +async function remoteVaultDelete(io: CliIO, name: string): Promise { + const target = await resolveVaultRemoteTarget(io); + if (target.kind === "self_hosted") return await target.client.request("vault_delete", { name }); + return await target.client.deleteVaultValue({ workspace: target.workspace, name }); +} + +async function remoteVaultAccessGrant( + io: CliIO, + input: { name: string; capletId: string; referenceName: string }, +): Promise { + const target = await resolveVaultRemoteTarget(io); + if (target.kind === "self_hosted") + return await target.client.request("vault_access_grant", input); + return await target.client.grantVaultAccess({ workspace: target.workspace, ...input }); +} + +async function remoteVaultAccessList( + io: CliIO, + input: { name?: string | undefined; capletId?: string | undefined }, +): Promise { + const target = await resolveVaultRemoteTarget(io); + if (target.kind === "self_hosted") { + return await target.client.request("vault_access_list", input); + } + return await target.client.listVaultAccess({ workspace: target.workspace, ...input }); +} + +async function remoteVaultAccessRevoke( + io: CliIO, + input: { name: string; capletId: string; referenceName?: string | undefined }, +): Promise { + const target = await resolveVaultRemoteTarget(io); + if (target.kind === "self_hosted") { + return await target.client.request("vault_access_revoke", input); + } + return await target.client.revokeVaultAccess({ workspace: target.workspace, ...input }); +} + +async function readVaultValue(io: CliIO): Promise { + let value: string; + if (io.readStdin) { + value = stripOneTrailingNewline(await io.readStdin()); + } else if (!process.stdin.isTTY && !io.writeOut && !io.writeErr) { + value = stripOneTrailingNewline(await readAllStdin()); + } else if (io.writeOut || io.writeErr || !process.stdin.isTTY || !process.stdout.isTTY) { + throw new CapletsError( + "REQUEST_INVALID", + "Vault value input is required. Run interactively or provide stdin.", + ); + } else { + const output = new HiddenPromptOutput(process.stdout); + const readline = createInterface({ input: process.stdin, output, terminal: true }); + try { + value = await readline.question("Vault value: "); + } finally { + readline.close(); + process.stdout.write("\n"); + } + } + if (value.length === 0) { + throw new CapletsError("REQUEST_INVALID", "Vault value input is required."); + } + return value; +} + +function stripOneTrailingNewline(value: string): string { + return value.replace(/\r?\n$/u, ""); +} + +function assertVaultTransportValueSize(value: string): void { + if (Buffer.byteLength(value, "utf8") > VAULT_MAX_VALUE_BYTES) { + throw new CapletsError( + "REQUEST_INVALID", + `Vault values must be ${VAULT_MAX_VALUE_BYTES} bytes or smaller.`, + ); + } +} + +function resolveVaultAccessOrigin(capletId: string, io: CliIO): ConfigSource { + const env = io.env ?? process.env; + const configPath = envConfigPath(env); + const projectConfigPath = envProjectConfigPath(env); + const config = loadConfigWithSources(configPath, projectConfigPath, { + vaultResolver: vaultBootstrapResolver, + }); + if (config.shadows[capletId]?.length) { + throw new CapletsError( + "REQUEST_INVALID", + `Caplet ${capletId} is shadowed in multiple config sources; resolve the active config before granting Vault access.`, + ); + } + const origin = config.sources[capletId]; + if (!origin) { + throw new CapletsError("SERVER_NOT_FOUND", `Caplet ${capletId} is not configured.`); + } + return origin; +} + +function vaultAccessFilter( + storedKey?: string, + capletId?: string, + referenceName?: string, +): VaultAccessGrantFilter { + return { + ...(storedKey ? { storedKey: validateVaultKeyName(storedKey) } : {}), + ...(capletId ? { capletId } : {}), + ...(referenceName ? { referenceName: validateVaultKeyName(referenceName) } : {}), + }; +} + function localMutationTargetLabel(target: Exclude, io: CliIO): string { return remoteClientForCli(io) ? `${target} ` : ""; } @@ -3098,6 +3617,7 @@ function mergePartialLocalOverlays( sources, shadows, warnings: [...globalOverlay.warnings, ...projectOverlay.warnings], + sourceFound: globalOverlay.sourceFound || projectOverlay.sourceFound, }; } diff --git a/packages/core/src/cli/auth.ts b/packages/core/src/cli/auth.ts index 3b6ca006..21baa893 100644 --- a/packages/core/src/cli/auth.ts +++ b/packages/core/src/cli/auth.ts @@ -13,6 +13,8 @@ import { loadConfig, loadGlobalConfig, loadProjectConfig, + vaultBootstrapResolver, + vaultResolverForAuthDir, type CapletsConfig, type GoogleDiscoveryApiConfig, type GraphQlEndpointConfig, @@ -45,7 +47,7 @@ export async function loginAuth( config?: CapletsConfig; }, ): Promise { - const config = options.config ?? loadConfig(options.configPath); + const config = options.config ?? loadAuthResolvedConfig(options); const server = await resolveAuthTarget(serverId, config, options.authDir); assertLoginTarget(server, serverId); @@ -89,7 +91,11 @@ export function logoutAuthResult( serverId: string, options: { authDir?: string; configPath?: string; config?: CapletsConfig }, ): { server: string; deleted: boolean } { - const target = findAuthTarget(serverId, options.config ?? loadConfig(options.configPath)); + const target = findAuthTarget( + serverId, + options.config ?? + loadConfig(options.configPath, undefined, { vaultResolver: vaultBootstrapResolver }), + ); assertLoginTarget(target, serverId); return { server: serverId, deleted: deleteTokenBundle(serverId, options.authDir) }; } @@ -113,7 +119,7 @@ export async function refreshAuthResult( ): Promise<{ server: string }> { const target = await resolveAuthTarget( serverId, - options.config ?? loadConfig(options.configPath), + options.config ?? loadAuthResolvedConfig(options), options.authDir, ); assertLoginTarget(target, serverId); @@ -137,10 +143,21 @@ export function listAuth(options: { } export function listAuthRows(options: { authDir?: string; configPath?: string }): AuthStatusRow[] { - const config = loadConfig(options.configPath); + const config = loadConfig(options.configPath, undefined, { + vaultResolver: vaultBootstrapResolver, + }); return authRowsForTargets(authTargets(config), options.authDir); } +function loadAuthResolvedConfig(options: { + authDir?: string | undefined; + configPath?: string | undefined; +}): CapletsConfig { + return loadConfig(options.configPath, undefined, { + vaultResolver: vaultResolverForAuthDir(options.authDir), + }); +} + export function listLocalAuthRows(options: { authDir?: string; configPath?: string; @@ -163,6 +180,7 @@ export function localAuthTargets(options: { export function localAuthConfigForTarget(options: { serverId: string; + authDir?: string | undefined; configPath?: string; projectConfigPath?: string; source: Exclude; @@ -171,7 +189,9 @@ export function localAuthConfigForTarget(options: { (candidate) => candidate.server === options.serverId, ); assertLoginTarget(target, options.serverId); - return loadConfigForSource(options.source, options); + return loadConfigForSource(options.source, options, { + vaultResolver: vaultResolverForAuthDir(options.authDir), + }); } function authTargetsForSource( @@ -194,11 +214,12 @@ function authTargetsForSource( function loadConfigForSource( source: Exclude, options: { configPath?: string; projectConfigPath?: string }, + loadOptions: Parameters[1] = { vaultResolver: vaultBootstrapResolver }, ): CapletsConfig { if (source === "global") { - return loadGlobalConfig(options.configPath); + return loadGlobalConfig(options.configPath, loadOptions); } - return loadProjectConfig(options.projectConfigPath); + return loadProjectConfig(options.projectConfigPath, loadOptions); } function authRowsForTargets( diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index 56bba63b..b29f84b4 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -32,6 +32,7 @@ export const cliCommands = { complete: "complete", config: "config", auth: "auth", + vault: "vault", } as const; export const topLevelCommandNames = [ @@ -63,6 +64,7 @@ export const topLevelCommandNames = [ cliCommands.complete, cliCommands.config, cliCommands.auth, + cliCommands.vault, cliCommands.completion, ] as const; @@ -76,12 +78,16 @@ export const cliSubcommands = { [cliCommands.config]: ["path", "paths"], [cliCommands.daemon]: ["install", "uninstall", "start", "restart", "stop", "status", "logs"], [cliCommands.setup]: ["codex", "claude-code", "opencode", "pi", "mcp-client"], + [cliCommands.vault]: ["set", "get", "list", "delete", "access"], } as const satisfies Record; export const cliNestedSubcommands = { [cliCommands.remote]: { host: ["pair", "clients", "revoke"], }, + [cliCommands.vault]: { + access: ["grant", "list", "revoke"], + }, } as const satisfies Record>; export const capletIdCommands = new Set([ diff --git a/packages/core/src/cli/doctor.ts b/packages/core/src/cli/doctor.ts index e9ceb7ee..64467ad8 100644 --- a/packages/core/src/cli/doctor.ts +++ b/packages/core/src/cli/doctor.ts @@ -28,7 +28,7 @@ import { resolveProjectConfigPath, } from "../config/paths"; import { FileObservedOutputShapeStore } from "../observed-output-shapes"; -import { loadConfig, type CapletConfig } from "../config"; +import { loadConfig, loadLocalOverlayConfigWithSources, type CapletConfig } from "../config"; import { resolveExposure } from "../exposure/policy"; import { daemonStatus, type DaemonOperationOptions } from "../daemon"; @@ -49,6 +49,7 @@ export type DoctorJsonReport = { sync: Record; daemon: Record; remoteLogin: Record; + vault: Record; exposure: Record; codeMode: Record; }; @@ -96,6 +97,7 @@ export async function doctorJsonReport(options: DoctorOptions = {}): Promise>).map( + (issue) => ` ${issue.capletId}: ${issue.reason} ${issue.key} (${issue.recoveryCommand})`, + ) + : []), + "", "Exposure", ` Default: ${report.exposure.default ?? "unknown"}`, ` Discovery timeout: ${report.exposure.discoveryTimeoutMs ?? "unknown"}ms`, @@ -181,6 +194,47 @@ export async function formatDoctorReport(options: DoctorOptions = {}): Promise, + cwd: string = process.cwd(), +) { + const configPath = env.CAPLETS_CONFIG?.trim() ? env.CAPLETS_CONFIG.trim() : resolveConfigPath(); + const projectConfigPath = env.CAPLETS_PROJECT_CONFIG?.trim() + ? env.CAPLETS_PROJECT_CONFIG.trim() + : resolveProjectConfigPath(cwd); + try { + const overlay = loadLocalOverlayConfigWithSources(configPath, projectConfigPath); + const issues = overlay.warnings + .filter((warning) => warning.message.includes("Vault key")) + .map((warning) => vaultIssueFromWarning(warning.message, warning.path)) + .filter((issue): issue is NonNullable => issue !== undefined); + return { ok: issues.length === 0, issues }; + } catch (error) { + return { + ok: false, + issues: [], + message: error instanceof Error ? error.message : String(error), + }; + } +} + +function vaultIssueFromWarning(message: string, path: string) { + const match = message.match( + /^Caplet ([^ ]+) references ([^ ]+) Vault key ([^ ]+) at ([^;]+); run `([^`]+)`/u, + ); + if (!match) return undefined; + const recoveryCommand = match[5] ?? ""; + return { + capletId: match[1], + reason: match[2], + key: match[3], + configPath: path, + referencePath: match[4], + target: recoveryCommand.includes("--remote") ? "remote" : "global", + recoveryCommand, + }; +} + async function resolveDaemonSection( env: NodeJS.ProcessEnv | Record, options: DaemonOperationOptions | undefined, diff --git a/packages/core/src/cli/vault.ts b/packages/core/src/cli/vault.ts new file mode 100644 index 00000000..a8c8a28f --- /dev/null +++ b/packages/core/src/cli/vault.ts @@ -0,0 +1,48 @@ +import type { VaultAccessGrant, VaultDeleteStatus, VaultValueStatus } from "../vault"; + +export function formatVaultValueStatus(status: VaultValueStatus, json = false): string { + if (json) return `${JSON.stringify(status, null, 2)}\n`; + if (!status.present) return `Vault key ${status.key} is not set.\n`; + return [ + `Vault key ${status.key} is set.`, + status.valueBytes === undefined ? undefined : `Value bytes: ${status.valueBytes}`, + status.updatedAt === undefined ? undefined : `Updated: ${status.updatedAt}`, + ] + .filter((line): line is string => line !== undefined) + .join("\n") + .concat("\n"); +} + +export function formatVaultValueList(statuses: VaultValueStatus[], json = false): string { + if (json) return `${JSON.stringify(statuses, null, 2)}\n`; + if (statuses.length === 0) return "No Vault keys set.\n"; + return `${statuses.map((status) => status.key).join("\n")}\n`; +} + +export function formatVaultDeleteStatus(status: VaultDeleteStatus, json = false): string { + if (json) return `${JSON.stringify(status, null, 2)}\n`; + return status.deleted + ? `Deleted Vault key ${status.key}. ${status.grantsRetained} access grant${status.grantsRetained === 1 ? "" : "s"} retained.\n` + : `No Vault key ${status.key} found.\n`; +} + +export function formatVaultAccessGrant(grant: VaultAccessGrant, json = false): string { + if (json) return `${JSON.stringify(grant, null, 2)}\n`; + return `Granted Vault key ${grant.storedKey} to ${grant.capletId} as ${grant.referenceName}.\n`; +} + +export function formatVaultAccessList(grants: VaultAccessGrant[], json = false): string { + if (json) return `${JSON.stringify(grants, null, 2)}\n`; + if (grants.length === 0) return "No Vault access grants.\n"; + return `${grants + .map((grant) => { + const origin = grant.origin ? ` (${grant.origin.kind} ${grant.origin.path})` : ""; + return `${grant.storedKey} -> ${grant.capletId}:${grant.referenceName}${origin}`; + }) + .join("\n")}\n`; +} + +export function formatVaultAccessRevoke(count: number, json = false): string { + if (json) return `${JSON.stringify({ revoked: count }, null, 2)}\n`; + return `Revoked ${count} Vault access grant${count === 1 ? "" : "s"}.\n`; +} diff --git a/packages/core/src/cloud/client.ts b/packages/core/src/cloud/client.ts index 871538bb..6b0dd025 100644 --- a/packages/core/src/cloud/client.ts +++ b/packages/core/src/cloud/client.ts @@ -22,6 +22,53 @@ export type RegisterPresenceResult = { export type HeartbeatPresenceResult = RegisterPresenceResult; +export type CloudVaultValueStatus = { + key: string; + present: boolean; + valueBytes?: number | undefined; + createdAt?: string | undefined; + updatedAt?: string | undefined; +}; + +export type CloudVaultAccessGrant = { + storedKey: string; + referenceName: string; + capletId: string; + origin?: { kind: string; path: string } | undefined; + createdAt?: string | undefined; + updatedAt?: string | undefined; +}; + +export type CloudVaultSetInput = { + workspace: string; + name: string; + value: string; + force?: boolean | undefined; + grant?: string | undefined; + referenceName?: string | undefined; +}; + +export type CloudVaultNameInput = { + workspace: string; + name: string; +}; + +export type CloudVaultGetInput = CloudVaultNameInput & { + reveal?: boolean | undefined; + revealContext?: "human-cli" | undefined; +}; + +export type CloudVaultAccessGrantInput = CloudVaultNameInput & { + capletId: string; + referenceName?: string | undefined; +}; + +export type CloudVaultAccessListInput = { + workspace: string; + name?: string | undefined; + capletId?: string | undefined; +}; + export class CapletsCloudClient { private readonly fetchImpl: typeof fetch; @@ -100,6 +147,130 @@ export class CapletsCloudClient { } } + async setVaultValue(input: CloudVaultSetInput): Promise { + const response = await this.fetchImpl( + this.endpoint( + `api/workspaces/${encodeURIComponent(input.workspace)}/vault/values/${encodeURIComponent(input.name)}`, + ), + { + method: "PUT", + headers: this.headers({ json: true }), + body: JSON.stringify({ + value: input.value, + force: Boolean(input.force), + ...(input.grant ? { grant: input.grant } : {}), + ...(input.referenceName ? { referenceName: input.referenceName } : {}), + }), + }, + ); + if (!response.ok) { + throw new Error(`Caplets Cloud Vault set failed: HTTP ${response.status}`); + } + return (await response.json()) as CloudVaultValueStatus; + } + + async getVaultValue( + input: CloudVaultGetInput, + ): Promise { + const url = this.endpoint( + `api/workspaces/${encodeURIComponent(input.workspace)}/vault/values/${encodeURIComponent(input.name)}`, + ); + if (input.reveal) { + url.searchParams.set("reveal", "true"); + url.searchParams.set("revealContext", input.revealContext ?? "human-cli"); + } + const response = await this.fetchImpl(url, { + method: "GET", + headers: this.headers(), + }); + if (!response.ok) { + throw new Error(`Caplets Cloud Vault get failed: HTTP ${response.status}`); + } + return (await response.json()) as CloudVaultValueStatus | { key: string; value: string }; + } + + async listVaultValues(input: { workspace: string }): Promise { + const response = await this.fetchImpl( + this.endpoint(`api/workspaces/${encodeURIComponent(input.workspace)}/vault/values`), + { + method: "GET", + headers: this.headers(), + }, + ); + if (!response.ok) { + throw new Error(`Caplets Cloud Vault list failed: HTTP ${response.status}`); + } + return (await response.json()) as CloudVaultValueStatus[]; + } + + async deleteVaultValue( + input: CloudVaultNameInput, + ): Promise<{ key: string; deleted: boolean; grantsRetained?: number | undefined }> { + const response = await this.fetchImpl( + this.endpoint( + `api/workspaces/${encodeURIComponent(input.workspace)}/vault/values/${encodeURIComponent(input.name)}`, + ), + { + method: "DELETE", + headers: this.headers(), + }, + ); + if (!response.ok) { + throw new Error(`Caplets Cloud Vault delete failed: HTTP ${response.status}`); + } + return (await response.json()) as { + key: string; + deleted: boolean; + grantsRetained?: number | undefined; + }; + } + + async grantVaultAccess(input: CloudVaultAccessGrantInput): Promise { + const response = await this.fetchImpl( + this.endpoint( + `api/workspaces/${encodeURIComponent(input.workspace)}/vault/access/${encodeURIComponent(input.name)}/${encodeURIComponent(input.capletId)}`, + ), + { + method: "PUT", + headers: this.headers({ json: true }), + body: JSON.stringify({ referenceName: input.referenceName ?? input.name }), + }, + ); + if (!response.ok) { + throw new Error(`Caplets Cloud Vault access grant failed: HTTP ${response.status}`); + } + return (await response.json()) as CloudVaultAccessGrant; + } + + async listVaultAccess(input: CloudVaultAccessListInput): Promise { + const url = this.endpoint(`api/workspaces/${encodeURIComponent(input.workspace)}/vault/access`); + if (input.name) url.searchParams.set("name", input.name); + if (input.capletId) url.searchParams.set("capletId", input.capletId); + const response = await this.fetchImpl(url, { + method: "GET", + headers: this.headers(), + }); + if (!response.ok) { + throw new Error(`Caplets Cloud Vault access list failed: HTTP ${response.status}`); + } + return (await response.json()) as CloudVaultAccessGrant[]; + } + + async revokeVaultAccess(input: CloudVaultAccessGrantInput): Promise { + const url = this.endpoint( + `api/workspaces/${encodeURIComponent(input.workspace)}/vault/access/${encodeURIComponent(input.name)}/${encodeURIComponent(input.capletId)}`, + ); + if (input.referenceName) url.searchParams.set("referenceName", input.referenceName); + const response = await this.fetchImpl(url, { + method: "DELETE", + headers: this.headers(), + }); + if (!response.ok) { + throw new Error(`Caplets Cloud Vault access revoke failed: HTTP ${response.status}`); + } + return (await response.json()) as CloudVaultAccessGrant[]; + } + private headers(options: { json?: boolean } = {}): Headers { const headers = new Headers(); headers.set("authorization", `Bearer ${this.options.accessToken}`); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 2e583c74..23ea1399 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,11 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { basename, dirname, isAbsolute, join } from "node:path"; import { z } from "zod"; -import { - loadCapletFiles, - loadCapletFilesWithPaths, - loadCapletFilesWithPathsBestEffort, -} from "./caplet-files"; +import { loadCapletFilesWithPaths, loadCapletFilesWithPathsBestEffort } from "./caplet-files"; import { resolveCapletsRoot, resolveConfigPath, resolveProjectConfigPath } from "./config/paths"; import { FORBIDDEN_HEADERS, @@ -19,6 +15,7 @@ import { } from "./config/validation"; import { CapletsError, redactSecrets } from "./errors"; import { nestedSchema, schemaPath } from "./schema-utils"; +import { FileVaultStore, validateVaultKeyName, type VaultConfigOrigin } from "./vault"; export { DEFAULT_AUTH_DIR, @@ -364,9 +361,36 @@ export type LocalOverlayConfigWarning = { export type LocalOverlayConfigWithSources = ConfigWithSources & { warnings: LocalOverlayConfigWarning[]; + sourceFound: boolean; +}; + +export type ConfigVaultReference = { + referenceName: string; + capletId: string; + origin: VaultConfigOrigin; + path: string; +}; + +export type ConfigVaultResolution = + | { storedKey: string; value: string } + | { + reason: "missing" | "ungranted" | "unavailable" | "invalid-key-source"; + storedKey?: string | undefined; + referenceName: string; + capletId: string; + origin: VaultConfigOrigin; + }; + +export type ConfigVaultResolver = (reference: ConfigVaultReference) => ConfigVaultResolution; + +export type ConfigParseOptions = { + sources?: Record | undefined; + vaultResolver?: ConfigVaultResolver | undefined; + vaultRecoveryTarget?: "global" | "remote" | undefined; }; const NON_INTERPOLATED_SERVER_FIELDS = new Set(["name", "description", "tags", "body"]); +const VAULT_BARE_REFERENCE = "[A-Za-z0-9_-]+"; const remoteAuthSchema = z .discriminatedUnion("type", [ @@ -1571,13 +1595,15 @@ export function configJsonSchema(): unknown { export function loadConfig( path = resolveConfigPath(), projectPath = resolveProjectConfigPath(), + options: Pick = {}, ): CapletsConfig { - return loadConfigWithSources(path, projectPath).config; + return loadConfigWithSources(path, projectPath, options).config; } export function loadConfigWithSources( path = resolveConfigPath(), projectPath = resolveProjectConfigPath(), + options: Pick = {}, ): ConfigWithSources { const hasUserConfig = existsSync(path); const hasProjectConfig = existsSync(projectPath); @@ -1607,10 +1633,14 @@ export function loadConfigWithSources( ], `Caplets config not found at ${path} or ${projectPath}`, "Caplets config must define at least one MCP server, OpenAPI endpoint, Google Discovery API, GraphQL endpoint, HTTP API, CLI tools backend, or Caplet set", + options, ); } -export function loadGlobalConfig(path = resolveConfigPath()): CapletsConfig { +export function loadGlobalConfig( + path = resolveConfigPath(), + options: Pick = {}, +): CapletsConfig { const userConfig = existsSync(path) ? readPublicConfigInput(path) : undefined; const userCaplets = loadCapletFilesWithPaths(resolveCapletsRoot(path)); @@ -1623,10 +1653,14 @@ export function loadGlobalConfig(path = resolveConfigPath()): CapletsConfig { ], `Caplets user config not found at ${path}`, undefined, + options, ).config; } -export function loadProjectConfig(projectPath = resolveProjectConfigPath()): CapletsConfig { +export function loadProjectConfig( + projectPath = resolveProjectConfigPath(), + options: Pick = {}, +): CapletsConfig { const projectConfig = existsSync(projectPath) ? rejectProjectConfigExecutableBackendMaps(readPublicConfigInput(projectPath), projectPath) : undefined; @@ -1647,6 +1681,7 @@ export function loadProjectConfig(projectPath = resolveProjectConfigPath()): Cap ], `Caplets project config not found at ${projectPath}`, undefined, + options, ).config; } @@ -1654,6 +1689,7 @@ function buildConfigWithSources( inputs: Array, notFoundMessage: string, emptyMessage: string | undefined, + options: Pick = {}, ): ConfigWithSources { if (!inputs.some((entry) => entry?.input !== undefined)) { throw new CapletsError("CONFIG_NOT_FOUND", notFoundMessage); @@ -1661,7 +1697,10 @@ function buildConfigWithSources( try { const { input, sources, shadows } = mergeConfigInputsWithSources(...inputs); - const config = parseConfig(input); + const config = parseConfig(input, { + sources, + vaultResolver: options.vaultResolver ?? defaultVaultResolver(), + }); if ( emptyMessage && Object.keys(config.mcpServers).length === 0 && @@ -1690,21 +1729,36 @@ function buildConfigWithSources( export function loadLocalOverlayConfigWithSources( path = resolveConfigPath(), projectPath = resolveProjectConfigPath(), + options: Pick = {}, ): LocalOverlayConfigWithSources { + const parseOptions = { + vaultResolver: options.vaultResolver ?? defaultVaultResolver(), + vaultRecoveryTarget: options.vaultRecoveryTarget, + }; const warnings: LocalOverlayConfigWarning[] = []; const userConfig = existsSync(path) - ? readBestEffortConfigInput(path, "global-config", warnings) + ? readBestEffortConfigInput(path, "global-config", warnings, undefined, parseOptions) : undefined; - const userCaplets = loadBestEffortCapletFiles(resolveCapletsRoot(path), "global-file", warnings); + const userCaplets = loadBestEffortCapletFiles( + resolveCapletsRoot(path), + "global-file", + warnings, + parseOptions, + ); const projectConfig = existsSync(projectPath) - ? readBestEffortConfigInput(projectPath, "project-config", warnings, (input) => - rejectProjectConfigExecutableBackendMaps(input, projectPath), + ? readBestEffortConfigInput( + projectPath, + "project-config", + warnings, + (input) => rejectProjectConfigExecutableBackendMaps(input, projectPath), + parseOptions, ) : undefined; const projectCapletsRoot = resolveProjectCapletsRootForConfigPath(projectPath); const projectCaplets = projectCapletsRoot - ? loadBestEffortCapletFiles(projectCapletsRoot, "project-file", warnings) + ? loadBestEffortCapletFiles(projectCapletsRoot, "project-file", warnings, parseOptions) : undefined; + const sourceFound = Boolean(userConfig || userCaplets || projectConfig || projectCaplets); const { input, sources, shadows } = mergeConfigInputsWithSources( { input: userConfig, source: { kind: "global-config", path } }, @@ -1720,7 +1774,53 @@ export function loadLocalOverlayConfigWithSources( : undefined, ); - return { config: parseConfig(input), sources, shadows, warnings }; + return { + config: parseConfig(input, { sources, vaultResolver: parseOptions.vaultResolver }), + sources, + shadows, + warnings, + sourceFound, + }; +} + +export function loadLocalRuntimeConfig( + path = resolveConfigPath(), + projectPath = resolveProjectConfigPath(), + options: Pick & { + writeWarning?: ((warning: LocalOverlayConfigWarning) => void) | undefined; + } = {}, +): CapletsConfig { + const overlay = loadLocalOverlayConfigWithSources(path, projectPath, { + vaultResolver: options.vaultResolver, + vaultRecoveryTarget: options.vaultRecoveryTarget, + }); + for (const warning of overlay.warnings) { + options.writeWarning?.(warning); + } + const blockingWarning = overlay.warnings.find( + (warning) => + !warning.recoverable && + (warning.kind === "global-config" || warning.kind === "project-config"), + ); + if (blockingWarning) { + throw new CapletsError("CONFIG_INVALID", blockingWarning.message); + } + if (!overlay.sourceFound) { + throw new CapletsError( + "CONFIG_NOT_FOUND", + `Caplets config not found at ${path} or ${projectPath}`, + ); + } + if ( + !configHasAnyCaplets(overlay.config) && + !overlay.warnings.some((warning) => warning.recoverable) + ) { + throw new CapletsError( + "CONFIG_INVALID", + "Caplets config must define at least one MCP server, OpenAPI endpoint, Google Discovery API, GraphQL endpoint, HTTP API, CLI tools backend, or Caplet set", + ); + } + return overlay.config; } function readBestEffortConfigInput( @@ -1728,13 +1828,26 @@ function readBestEffortConfigInput( kind: ConfigSourceKind, warnings: LocalOverlayConfigWarning[], transform?: (input: ConfigInput) => ConfigInput, + options: Pick = {}, ): ConfigInput | undefined { try { const input = readBestEffortJsonConfigInput(path); const normalized = normalizeLocalPaths(input, dirname(path)); const transformed = transform ? transform(normalized) : normalized; - const filtered = quarantineMissingEnvCaplets(transformed, kind, path, warnings); - const parsed = configFileSchema.safeParse(interpolateConfig(filtered)); + const filtered = quarantineUnresolvedReferenceCaplets( + transformed, + kind, + path, + warnings, + options, + ); + const validationOptions = { + ...options, + sources: Object.fromEntries( + capletIds(filtered).map((id) => [id, { kind, path } satisfies ConfigSource]), + ), + }; + const parsed = configFileSchema.safeParse(interpolateConfig(filtered, [], validationOptions)); if (!parsed.success) { throw new CapletsError( "CONFIG_INVALID", @@ -1761,11 +1874,12 @@ function readBestEffortJsonConfigInput(path: string): ConfigInput { } } -function quarantineMissingEnvCaplets( +function quarantineUnresolvedReferenceCaplets( input: ConfigInput, kind: ConfigSourceKind, sourcePath: string | ((id: string) => string), warnings: LocalOverlayConfigWarning[], + options: Pick = {}, ): ConfigInput { let filtered = input; @@ -1776,18 +1890,38 @@ function quarantineMissingEnvCaplets( } for (const [id, caplet] of Object.entries(caplets)) { - const missing = missingEnvReferences(caplet, [backend, id]); - if (missing.length === 0) { + const envMissing = missingEnvReferences(caplet, [backend, id]); + const capletSourcePath = typeof sourcePath === "function" ? sourcePath(id) : sourcePath; + const vaultIssues = unresolvedVaultReferences( + caplet, + [backend, id], + { + capletId: id, + origin: { kind, path: capletSourcePath }, + }, + options, + ); + if (envMissing.length === 0 && vaultIssues.length === 0) { continue; } filtered = removeCapletBackendId(filtered, backend, id); - warnings.push({ - kind, - path: typeof sourcePath === "function" ? sourcePath(id) : sourcePath, - message: formatMissingEnvWarning(id, missing), - recoverable: true, - }); + for (const missing of groupMissingEnvReferences(envMissing)) { + warnings.push({ + kind, + path: capletSourcePath, + message: formatMissingEnvWarning(id, missing), + recoverable: true, + }); + } + for (const issue of vaultIssues) { + warnings.push({ + kind, + path: capletSourcePath, + message: formatVaultReferenceWarning(id, issue, options.vaultRecoveryTarget), + recoverable: true, + }); + } } } @@ -1831,10 +1965,150 @@ function formatMissingEnvWarning(id: string, missing: MissingEnvReference[]): st return `Caplet ${id} references missing ${variableLabel} ${names.join(", ")} at ${paths.join(", ")}; skipping Caplet ${id}.`; } +function groupMissingEnvReferences(missing: MissingEnvReference[]): MissingEnvReference[][] { + return missing.length === 0 ? [] : [missing]; +} + +function configHasAnyCaplets(config: CapletsConfig): boolean { + return ( + Object.keys(config.mcpServers).length > 0 || + Object.keys(config.openapiEndpoints).length > 0 || + Object.keys(config.googleDiscoveryApis).length > 0 || + Object.keys(config.graphqlEndpoints).length > 0 || + Object.keys(config.httpApis).length > 0 || + Object.keys(config.cliTools).length > 0 || + Object.keys(config.capletSets).length > 0 + ); +} + +type VaultReferenceIssue = { + name: string; + path: string; + reason: Exclude["reason"]; + storedKey?: string | undefined; +}; + +function unresolvedVaultReferences( + value: unknown, + path: string[], + context: Pick, + options: Pick, +): VaultReferenceIssue[] { + if (isPublicMetadataPath(path)) { + return []; + } + if (typeof value === "string") { + return unresolvedVaultReferencesInString(value, path.join("."), context, options); + } + if (Array.isArray(value)) { + return value.flatMap((item, index) => + unresolvedVaultReferences(item, [...path, String(index)], context, options), + ); + } + if (isPlainObject(value)) { + return Object.entries(value).flatMap(([key, nested]) => + unresolvedVaultReferences(nested, [...path, key], context, options), + ); + } + return []; +} + +function unresolvedVaultReferencesInString( + value: string, + path: string, + context: Pick, + options: Pick, +): VaultReferenceIssue[] { + const issues: VaultReferenceIssue[] = []; + for (const match of value.matchAll(VAULT_REFERENCE_PATTERN)) { + const name = match[1] ?? match[2]; + if (!name) continue; + try { + validateVaultKeyName(name); + } catch { + issues.push({ name, path, reason: "invalid-key-source" }); + continue; + } + const resolution = options.vaultResolver?.({ + referenceName: name, + capletId: context.capletId, + origin: context.origin, + path, + }); + if (!resolution || !("value" in resolution)) { + const reason = resolution && "reason" in resolution ? resolution.reason : "unavailable"; + issues.push({ + name, + path, + reason, + ...(resolution && "storedKey" in resolution && resolution.storedKey + ? { storedKey: resolution.storedKey } + : {}), + }); + } + } + return issues; +} + +function formatVaultReferenceWarning( + id: string, + issue: VaultReferenceIssue, + target: ConfigParseOptions["vaultRecoveryTarget"], +): string { + const key = issue.storedKey ?? issue.name; + const targetFlag = target === "remote" ? " --remote" : ""; + if (issue.reason === "invalid-key-source") { + return `Caplet ${id} references invalid-key-source Vault key ${key} at ${issue.path}; run \`caplets doctor\` for key-source details, then reload Caplets; skipping Caplet ${id}.`; + } + if (issue.reason === "missing") { + return `Caplet ${id} references missing Vault key ${key} at ${issue.path}; run \`caplets vault set ${key}${targetFlag}\`, then reload Caplets; skipping Caplet ${id}.`; + } + const remapFlag = key !== issue.name ? ` --as ${issue.name}` : ""; + const grantCommand = `caplets vault access grant ${key} ${id}${targetFlag}${remapFlag}`; + return `Caplet ${id} references ${issue.reason} Vault key ${key} at ${issue.path}; run \`${grantCommand}\` after setting the value, then reload Caplets; skipping Caplet ${id}.`; +} + +export function defaultVaultResolver(store = new FileVaultStore()): ConfigVaultResolver { + return (reference) => { + try { + return store.resolveGrantedValue(reference); + } catch (error) { + return { + reason: error instanceof CapletsError ? "invalid-key-source" : "unavailable", + referenceName: reference.referenceName, + capletId: reference.capletId, + origin: reference.origin, + }; + } + }; +} + +export function vaultStoreForAuthDir(authDir: string | undefined): FileVaultStore { + return new FileVaultStore(authDir ? { root: join(authDir, "vault") } : {}); +} + +export function vaultResolverForAuthDir(authDir: string | undefined): ConfigVaultResolver { + return defaultVaultResolver(vaultStoreForAuthDir(authDir)); +} + +export const vaultBootstrapResolver: ConfigVaultResolver = (reference) => ({ + storedKey: reference.referenceName, + value: vaultBootstrapPlaceholderValue(reference.path), +}); + +function vaultBootstrapPlaceholderValue(path: string): string { + const leaf = path.split(".").at(-1)?.toLowerCase() ?? ""; + if (leaf.endsWith("url") || leaf.endsWith("uri") || leaf === "issuer") { + return "https://caplets.local/vault-placeholder"; + } + return "caplets-vault-placeholder"; +} + function loadBestEffortCapletFiles( root: string, kind: ConfigSourceKind, warnings: LocalOverlayConfigWarning[], + options: Pick = {}, ): { config: ConfigInput; paths: Record } | undefined { const result = loadCapletFilesWithPathsBestEffort(root); if (!result) { @@ -1843,11 +2117,12 @@ function loadBestEffortCapletFiles( for (const warning of result.warnings) { warnings.push({ kind, path: warning.path ?? root, message: warning.message }); } - const config = quarantineMissingEnvCaplets( + const config = quarantineUnresolvedReferenceCaplets( result.config, kind, (id) => result.paths[id] ?? root, warnings, + options, ); const retainedIds = new Set(capletIds(config)); const paths = Object.fromEntries( @@ -1874,6 +2149,8 @@ export function loadIsolatedConfig(options: { capletsRoot?: string; defaultSearchLimit: number; maxSearchLimit: number; + vaultResolver?: ConfigVaultResolver | undefined; + vaultRecoveryTarget?: ConfigParseOptions["vaultRecoveryTarget"]; }): CapletsConfig { if (!options.configPath && !options.capletsRoot) { throw new CapletsError( @@ -1882,22 +2159,50 @@ export function loadIsolatedConfig(options: { ); } - const configInput = options.configPath ? readPublicConfigInput(options.configPath) : undefined; - const capletInput = options.capletsRoot ? loadCapletFiles(options.capletsRoot) : undefined; - if (!configInput && !capletInput) { + const warnings: LocalOverlayConfigWarning[] = []; + const parseOptions = { + vaultResolver: options.vaultResolver ?? defaultVaultResolver(), + vaultRecoveryTarget: options.vaultRecoveryTarget, + }; + const configExists = Boolean(options.configPath && existsSync(options.configPath)); + const configInput = configExists + ? readBestEffortConfigInput( + options.configPath!, + "global-config", + warnings, + undefined, + parseOptions, + ) + : undefined; + const capletInput = options.capletsRoot + ? loadBestEffortCapletFiles(options.capletsRoot, "global-file", warnings, parseOptions) + : undefined; + if (!configExists && !capletInput) { throw new CapletsError( "CONFIG_NOT_FOUND", `Nested Caplet set sources not found: ${[options.configPath, options.capletsRoot].filter(Boolean).join(", ")}`, ); } + const blockingWarning = warnings.find((warning) => !warning.recoverable); + if (blockingWarning) { + throw new CapletsError("CONFIG_INVALID", blockingWarning.message); + } - const config = parseConfig( - mergeConfigInputs(configInput, capletInput, { - version: 1, - defaultSearchLimit: options.defaultSearchLimit, - maxSearchLimit: options.maxSearchLimit, - }), + const { input, sources } = mergeConfigInputsWithSources( + { input: configInput, source: { kind: "global-config", path: options.configPath ?? "" } }, + capletInput + ? { input: capletInput.config, source: { kind: "global-file", path: capletInput.paths } } + : undefined, + { + input: { + version: 1, + defaultSearchLimit: options.defaultSearchLimit, + maxSearchLimit: options.maxSearchLimit, + }, + source: { kind: "global-config", path: options.configPath ?? "" }, + }, ); + const config = parseConfig(input, { sources, vaultResolver: parseOptions.vaultResolver }); if ( Object.keys(config.mcpServers).length === 0 && Object.keys(config.openapiEndpoints).length === 0 && @@ -1923,7 +2228,16 @@ function readPublicConfigInput(path: string): ConfigInput { try { const input = JSON.parse(readFileSync(path, "utf8")); const normalized = normalizeLocalPaths(input as ConfigInput, dirname(path)); - const parsed = configFileSchema.safeParse(interpolateConfig(normalized)); + const validationOptions = { + sources: Object.fromEntries( + capletIds(normalized).map((id) => [ + id, + { kind: "global-config", path } satisfies ConfigSource, + ]), + ), + vaultResolver: vaultBootstrapResolver, + }; + const parsed = configFileSchema.safeParse(interpolateConfig(normalized, [], validationOptions)); if (!parsed.success) { throw new CapletsError( "CONFIG_INVALID", @@ -2055,7 +2369,12 @@ function normalizeCapletSetPaths( } function normalizeLocalPath(value: unknown, baseDir: string): unknown { - if (typeof value !== "string" || !value || isAbsolute(value) || hasEnvReference(value)) { + if ( + typeof value !== "string" || + !value || + isAbsolute(value) || + hasInterpolationReference(value) + ) { return value; } return join(baseDir, value); @@ -2223,8 +2542,8 @@ function sourceForId(source: ConfigSourceInput, id: string): ConfigSource { }; } -export function parseConfig(input: unknown): CapletsConfig { - const parsed = normalizedConfigFileSchema.safeParse(interpolateConfig(input)); +export function parseConfig(input: unknown, options: ConfigParseOptions = {}): CapletsConfig { + const parsed = normalizedConfigFileSchema.safeParse(interpolateConfig(input, [], options)); if (!parsed.success) { throw new CapletsError("CONFIG_INVALID", "Caplets config is invalid", parsed.error.issues); } @@ -2346,21 +2665,23 @@ function stripUndefined>(value: T): T { ) as T; } -function interpolateConfig(value: T, path: string[] = []): T { +function interpolateConfig(value: T, path: string[] = [], options: ConfigParseOptions = {}): T { if (isPublicMetadataPath(path)) { return value; } if (typeof value === "string") { - return interpolateEnv(value) as T; + return interpolateVault(interpolateEnv(value), path, options) as T; } if (Array.isArray(value)) { - return value.map((item, index) => interpolateConfig(item, [...path, String(index)])) as T; + return value.map((item, index) => + interpolateConfig(item, [...path, String(index)], options), + ) as T; } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value).map(([nestedKey, nested]) => [ nestedKey, - interpolateConfig(nested, [...path, nestedKey]), + interpolateConfig(nested, [...path, nestedKey], options), ]), ) as T; } @@ -2378,8 +2699,40 @@ function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } -function hasEnvReference(value: string): boolean { - return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*/.test(value); +function hasInterpolationReference(value: string): boolean { + return new RegExp( + `\\$\\{[A-Za-z_][A-Za-z0-9_]*\\}|\\$env:[A-Za-z_][A-Za-z0-9_]*|\\$\\{vault:[^}]+\\}|\\$vault:${VAULT_BARE_REFERENCE}`, + ).test(value); +} + +const VAULT_REFERENCE_PATTERN = new RegExp( + `\\$\\{vault:([^}]+)\\}|\\$vault:(${VAULT_BARE_REFERENCE})`, + "g", +); + +function interpolateVault(value: string, path: string[], options: ConfigParseOptions): string { + if (!options.vaultResolver) return value; + const backend = path[0]; + const capletId = path[1]; + if (!backend || !capletId || !CAPLET_BACKEND_KEY_SET.has(backend)) return value; + const origin = options.sources?.[capletId]; + if (!origin) return value; + return value.replace(VAULT_REFERENCE_PATTERN, (_match, braced: string, bare: string) => { + const referenceName = validateVaultKeyName(braced ?? bare); + const resolution = options.vaultResolver?.({ + referenceName, + capletId, + origin, + path: path.join("."), + }); + if (!resolution || !("value" in resolution)) { + throw new CapletsError( + "CONFIG_INVALID", + `Vault key ${referenceName} is unresolved for Caplet ${capletId}`, + ); + } + return resolution.value; + }); } function patchConfigJsonSchema(schema: T): T { diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index dd5a4ed5..e577b7b1 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -6,10 +6,12 @@ import { findProjectRoot, fingerprintProjectRoot } from "./cloud/project-root"; import { type CapletConfig, type CapletsConfig, - loadConfig, + loadLocalRuntimeConfig, + type LocalOverlayConfigWarning, resolveCapletsRoot, resolveConfigPath, resolveProjectConfigPath, + vaultResolverForAuthDir, } from "./config"; import { DEFAULT_OBSERVED_OUTPUT_SHAPE_CACHE_DIR } from "./config/paths"; import { DownstreamManager } from "./downstream"; @@ -38,11 +40,16 @@ export type CapletsEngineOptions = { watchDebounceMs?: number; watch?: boolean; writeErr?: (value: string) => void; - configLoader?: (configPath: string, projectConfigPath: string) => CapletsConfig; + configLoader?: ( + configPath: string, + projectConfigPath: string, + options?: { writeWarning?: ((warning: LocalOverlayConfigWarning) => void) | undefined }, + ) => CapletsConfig; observedOutputShapeStore?: ObservedOutputShapeStore | undefined; observedOutputShapeScope?: ObservedOutputShapeKey["scope"] | undefined; observedOutputShapeCacheDir?: string | undefined; projectFingerprint?: string | undefined; + vaultRecoveryTarget?: "global" | "remote" | undefined; }; export type CapletsEngineReloadEvent = { @@ -74,7 +81,7 @@ export class CapletsEngine { private readonly watchDebounceMs: number; private readonly watchEnabled: boolean; private readonly writeErr: (value: string) => void; - private readonly configLoader: (configPath: string, projectConfigPath: string) => CapletsConfig; + private readonly configLoader: NonNullable; private readonly observedOutputShapeStore: ObservedOutputShapeStore | undefined; private readonly observedOutputShapeScope: ObservedOutputShapeKey["scope"]; private readonly projectFingerprint: string | undefined; @@ -92,8 +99,10 @@ export class CapletsEngine { configPath: resolveConfigPath(options.configPath), projectConfigPath: options.projectConfigPath ?? resolveProjectConfigPath(), }; - this.configLoader = options.configLoader ?? loadConfig; - const config = this.configLoader(this.paths.configPath, this.paths.projectConfigPath); + this.writeErr = options.writeErr ?? ((value: string) => process.stderr.write(value)); + this.configLoader = + options.configLoader ?? runtimeConfigLoader(options.authDir, options.vaultRecoveryTarget); + const config = this.loadConfigWithWarnings(); this.registry = new ServerRegistry(config); this.downstream = new DownstreamManager(this.registry, selectAuthOptions(options.authDir)); this.openapi = new OpenApiManager(this.registry, selectHttpLikeOptions(options)); @@ -107,7 +116,6 @@ export class CapletsEngine { this.capletSets = new CapletSetManager(this.registry, selectHttpLikeOptions(options)); this.watchDebounceMs = options.watchDebounceMs ?? 250; this.watchEnabled = options.watch ?? true; - this.writeErr = options.writeErr ?? ((value: string) => process.stderr.write(value)); this.observedOutputShapeStore = options.observedOutputShapeStore ?? new FileObservedOutputShapeStore( @@ -381,7 +389,7 @@ export class CapletsEngine { } let nextConfig: CapletsConfig; try { - nextConfig = this.configLoader(this.paths.configPath, this.paths.projectConfigPath); + nextConfig = this.loadConfigWithWarnings(); } catch (error) { this.writeErr(`Caplets config reload failed; keeping last known-good config.\n`); this.writeErr(`${JSON.stringify(toSafeError(error, "CONFIG_INVALID"), null, 2)}\n`); @@ -420,6 +428,14 @@ export class CapletsEngine { return invalidated; } + private loadConfigWithWarnings(): CapletsConfig { + return this.configLoader(this.paths.configPath, this.paths.projectConfigPath, { + writeWarning: (warning) => { + this.writeErr(`Warning: ${warning.kind} at ${warning.path}: ${warning.message}\n`); + }, + }); + } + private async reloadUntilSettled(): Promise { let succeeded = true; do { @@ -564,6 +580,19 @@ export class CapletsEngine { } } +function runtimeConfigLoader( + authDir: string | undefined, + vaultRecoveryTarget: CapletsEngineOptions["vaultRecoveryTarget"], +): NonNullable { + const vaultResolver = vaultResolverForAuthDir(authDir); + return (configPath, projectConfigPath, options) => + loadLocalRuntimeConfig(configPath, projectConfigPath, { + ...options, + vaultResolver, + vaultRecoveryTarget, + }); +} + function selectAuthOptions(authDir: string | undefined): { authDir?: string } { return authDir ? { authDir } : {}; } diff --git a/packages/core/src/remote-control/client.ts b/packages/core/src/remote-control/client.ts index c05f87f7..60a5474f 100644 --- a/packages/core/src/remote-control/client.ts +++ b/packages/core/src/remote-control/client.ts @@ -68,7 +68,7 @@ export class RemoteControlClient { if (!payload.ok) { throw new CapletsError( payload.error.code, - redactRemoteMessage(payload.error.message), + redactRemoteMessage(payload.error.message, sensitiveValues(command, args)), payload.error.nextAction === undefined ? undefined : { nextAction: payload.error.nextAction }, @@ -157,8 +157,13 @@ function isCapletsErrorCode(value: string): value is CapletsErrorCode { return CAPLETS_ERROR_CODES.includes(value as CapletsErrorCode); } -function redactRemoteMessage(message: string): string { - return String(redactSecrets(message)) +function redactRemoteMessage(message: string, values: string[] = []): string { + let redacted = String(redactSecrets(message)); + for (const value of values) { + if (value.length === 0) continue; + redacted = redacted.split(value).join("[REDACTED]"); + } + return redacted .replace(/\b(authorization\s*:\s*(?:basic|bearer)\s+)[^\s,;]+/giu, "$1[REDACTED]") .replace(/\b((?:access_)?token=)[^\s,;]+/giu, "$1[REDACTED]") .replace( @@ -166,3 +171,8 @@ function redactRemoteMessage(message: string): string { "$1[REDACTED]", ); } + +function sensitiveValues(command: RemoteCliCommand, args: RemoteCliRequest["arguments"]): string[] { + if (command === "vault_set" && typeof args.value === "string") return [args.value]; + return []; +} diff --git a/packages/core/src/remote-control/dispatch.ts b/packages/core/src/remote-control/dispatch.ts index d2b7de62..c84c0be4 100644 --- a/packages/core/src/remote-control/dispatch.ts +++ b/packages/core/src/remote-control/dispatch.ts @@ -18,10 +18,17 @@ import { completionShells, type CompletionShell } from "./../cli/completion"; import { initConfig } from "./../cli/init"; import { installCaplets } from "./../cli/install"; import { listCaplets } from "./../cli/inspection"; -import { loadConfigWithSources } from "../config"; +import { + loadConfigWithSources, + loadLocalOverlayConfigWithSources, + vaultBootstrapResolver, + vaultResolverForAuthDir, + vaultStoreForAuthDir, +} from "../config"; import { CapletsEngine, type CapletsEngineOptions } from "../engine"; import { CapletsError, toSafeError } from "../errors"; import { startGenericOAuthFlow, startOAuthFlow } from "../auth"; +import { FileVaultStore, validateVaultKeyName, type VaultAccessGrantInput } from "../vault"; import type { RemoteAuthFlowStore } from "./auth-flow"; import type { RemoteCliRequest, RemoteCliResponse } from "./types"; @@ -83,7 +90,9 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc assertObject(request.arguments, "remote control request arguments"); if (request.command === "list") { - const config = loadConfigWithSources(context.configPath, context.projectConfigPath); + const config = loadConfigWithSources(context.configPath, context.projectConfigPath, { + vaultResolver: vaultBootstrapResolver, + }); return listCaplets(config, { includeDisabled: optionalBoolean(request.arguments, "includeDisabled") ?? false, }); @@ -143,6 +152,10 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc }); } + if (request.command.startsWith("vault_")) { + return dispatchVault(request, context); + } + if (request.command === "auth_logout") { return logoutAuthResult(requiredString(request.arguments, "server"), { ...optionalProp("configPath", context.configPath), @@ -175,11 +188,110 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc ); } +function dispatchVault(request: RemoteCliRequest, context: RemoteControlDispatchContext) { + const store = remoteVaultStore(context); + switch (request.command) { + case "vault_set": { + const name = requiredString(request.arguments, "name"); + const value = requiredString(request.arguments, "value"); + const grant = optionalString(request.arguments, "grant"); + const grantInput = grant + ? ({ + storedKey: validateVaultKeyName(name), + referenceName: validateVaultKeyName( + optionalString(request.arguments, "referenceName") ?? name, + ), + capletId: grant, + origin: remoteVaultAccessOrigin(grant, context), + } satisfies VaultAccessGrantInput) + : undefined; + const existed = store.getStatus(name).present; + const previousValue = existed && grantInput ? store.resolveValue(name) : undefined; + const status = store.set(name, value, { + force: optionalBoolean(request.arguments, "force") ?? false, + }); + try { + if (grantInput) store.grantAccess(grantInput); + } catch (error) { + if (existed && previousValue !== undefined) { + store.set(name, previousValue, { force: true }); + } else { + store.delete(name); + } + throw error; + } + return { remote: true, ...status }; + } + case "vault_list": + return store.listValues(); + case "vault_get": { + const name = requiredString(request.arguments, "name"); + const reveal = optionalBoolean(request.arguments, "reveal") ?? false; + if (reveal) { + throw new CapletsError( + "REQUEST_INVALID", + "Self-hosted remote Vault reveal is not supported through remote control.", + ); + } + return store.getStatus(name); + } + case "vault_delete": + return store.delete(requiredString(request.arguments, "name")); + case "vault_access_grant": { + const storedKey = requiredString(request.arguments, "name"); + const capletId = requiredString(request.arguments, "capletId"); + return store.grantAccess({ + storedKey, + referenceName: optionalString(request.arguments, "referenceName") ?? storedKey, + capletId, + origin: remoteVaultAccessOrigin(capletId, context), + }); + } + case "vault_access_revoke": + return store.revokeAccess({ + storedKey: requiredString(request.arguments, "name"), + capletId: requiredString(request.arguments, "capletId"), + ...optionalProp("referenceName", optionalString(request.arguments, "referenceName")), + }); + case "vault_access_list": + return store.listAccess({ + ...optionalProp("storedKey", optionalString(request.arguments, "name")), + ...optionalProp("capletId", optionalString(request.arguments, "capletId")), + }); + default: + throw new CapletsError( + "UNKNOWN_OPERATION", + `Unsupported remote control command ${request.command}`, + ); + } +} + +function remoteVaultStore(context: RemoteControlDispatchContext): FileVaultStore { + return vaultStoreForAuthDir(context.authDir); +} + +function remoteVaultAccessOrigin(capletId: string, context: RemoteControlDispatchContext) { + const overlay = loadLocalOverlayConfigWithSources(context.configPath, context.projectConfigPath, { + vaultResolver: vaultBootstrapResolver, + }); + const origin = overlay.sources[capletId]; + if (!origin) throw new CapletsError("SERVER_NOT_FOUND", `Caplet ${capletId} is not configured.`); + if (overlay.shadows[capletId]?.length) { + throw new CapletsError( + "REQUEST_INVALID", + `Caplet ${capletId} is shadowed in multiple config sources; resolve the active config before granting Vault access.`, + ); + } + return origin; +} + async function startRemoteAuthLogin(serverId: string, context: RemoteControlDispatchContext) { if (!context.authFlowStore || !context.controlCallbackBaseUrl) { throw new CapletsError("REQUEST_INVALID", "Remote auth login is not available on this server"); } - const config = loadConfigWithSources(context.configPath, context.projectConfigPath).config; + const config = loadConfigWithSources(context.configPath, context.projectConfigPath, { + vaultResolver: vaultResolverForAuthDir(context.authDir), + }).config; const target = await resolveAuthTarget(serverId, config, context.authDir); assertLoginTarget(target, serverId); const flowId = randomUUID(); diff --git a/packages/core/src/remote-control/types.ts b/packages/core/src/remote-control/types.ts index 50981d15..48d446d0 100644 --- a/packages/core/src/remote-control/types.ts +++ b/packages/core/src/remote-control/types.ts @@ -24,7 +24,14 @@ export type RemoteCliCommand = | "auth_login_complete" | "auth_logout" | "auth_refresh" - | "auth_list"; + | "auth_list" + | "vault_set" + | "vault_list" + | "vault_get" + | "vault_delete" + | "vault_access_grant" + | "vault_access_revoke" + | "vault_access_list"; export type RemoteCliRequest = { command: RemoteCliCommand; diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index 0fa64ba6..2e1bff54 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -562,6 +562,7 @@ export async function serveHttp( ): Promise { const resolvedEngineOptions = { exposeLocalArtifactPaths: false, + vaultRecoveryTarget: "remote" as const, ...engineOptions, }; const engine = new CapletsEngine(resolvedEngineOptions); diff --git a/packages/core/src/vault/access.ts b/packages/core/src/vault/access.ts new file mode 100644 index 00000000..8b1d9ab1 --- /dev/null +++ b/packages/core/src/vault/access.ts @@ -0,0 +1,86 @@ +import { CapletsError } from "../errors"; +import { validateVaultKeyName } from "./keys"; +import type { + VaultAccessGrant, + VaultAccessGrantFilter, + VaultAccessGrantInput, + VaultConfigOrigin, +} from "./types"; + +export function normalizeVaultGrant(input: VaultAccessGrantInput): VaultAccessGrant { + const now = (input.now ?? new Date()).toISOString(); + return { + storedKey: validateVaultKeyName(input.storedKey), + referenceName: validateVaultKeyName(input.referenceName), + capletId: validateCapletId(input.capletId), + origin: normalizeOrigin(input.origin), + createdAt: now, + updatedAt: now, + }; +} + +export function upsertVaultGrant( + grants: VaultAccessGrant[], + input: VaultAccessGrantInput, +): VaultAccessGrant[] { + const next = normalizeVaultGrant(input); + return [ + ...grants.filter((grant) => !sameGrantIdentity(grant, next)), + { + ...next, + createdAt: + grants.find((grant) => sameGrantIdentity(grant, next))?.createdAt ?? next.createdAt, + }, + ].sort(compareGrants); +} + +export function filterVaultGrants( + grants: VaultAccessGrant[], + filter: VaultAccessGrantFilter = {}, +): VaultAccessGrant[] { + return grants.filter((grant) => { + if (filter.storedKey !== undefined && grant.storedKey !== filter.storedKey) return false; + if (filter.referenceName !== undefined && grant.referenceName !== filter.referenceName) { + return false; + } + if (filter.capletId !== undefined && grant.capletId !== filter.capletId) return false; + if (filter.origin !== undefined && !sameOrigin(grant.origin, filter.origin)) return false; + return true; + }); +} + +export function sameOrigin(left: VaultConfigOrigin, right: VaultConfigOrigin): boolean { + return left.kind === right.kind && left.path === right.path; +} + +function sameGrantIdentity(left: VaultAccessGrant, right: VaultAccessGrant): boolean { + return ( + left.referenceName === right.referenceName && + left.capletId === right.capletId && + sameOrigin(left.origin, right.origin) + ); +} + +function normalizeOrigin(origin: VaultConfigOrigin): VaultConfigOrigin { + if (!origin.path) { + throw new CapletsError("REQUEST_INVALID", "Vault access grants require a config origin path."); + } + return { kind: origin.kind, path: origin.path }; +} + +function validateCapletId(capletId: string): string { + if (!/^[a-zA-Z0-9_-]{1,64}$/u.test(capletId)) { + throw new CapletsError("REQUEST_INVALID", "Vault access grants require a valid Caplet ID."); + } + return capletId; +} + +function compareGrants(left: VaultAccessGrant, right: VaultAccessGrant): number { + return ( + left.capletId.localeCompare(right.capletId) || + left.referenceName.localeCompare(right.referenceName) || + left.storedKey.localeCompare(right.storedKey) || + left.origin.kind.localeCompare(right.origin.kind) || + left.origin.path.localeCompare(right.origin.path) + ); +} diff --git a/packages/core/src/vault/crypto.ts b/packages/core/src/vault/crypto.ts new file mode 100644 index 00000000..9d2670e9 --- /dev/null +++ b/packages/core/src/vault/crypto.ts @@ -0,0 +1,74 @@ +import { Buffer } from "node:buffer"; +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { CapletsError } from "../errors"; + +export type VaultEncryptedRecord = { + version: 1; + algorithm: "aes-256-gcm"; + nonce: string; + ciphertext: string; + authTag: string; + valueBytes: number; + createdAt: string; + updatedAt: string; +}; + +const NONCE_BYTES = 12; + +export function encryptVaultValue(input: { + plaintext: string; + key: Buffer; + now: Date; + existing?: VaultEncryptedRecord | undefined; +}): VaultEncryptedRecord { + const nonce = randomBytes(NONCE_BYTES); + const cipher = createCipheriv("aes-256-gcm", input.key, nonce); + const ciphertext = Buffer.concat([cipher.update(input.plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + const timestamp = input.now.toISOString(); + return { + version: 1, + algorithm: "aes-256-gcm", + nonce: nonce.toString("base64url"), + ciphertext: ciphertext.toString("base64url"), + authTag: authTag.toString("base64url"), + valueBytes: Buffer.byteLength(input.plaintext, "utf8"), + createdAt: input.existing?.createdAt ?? timestamp, + updatedAt: timestamp, + }; +} + +export function decryptVaultValue(record: unknown, key: Buffer): string { + const parsed = parseEncryptedRecord(record); + try { + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(parsed.nonce, "base64url")); + decipher.setAuthTag(Buffer.from(parsed.authTag, "base64url")); + return Buffer.concat([ + decipher.update(Buffer.from(parsed.ciphertext, "base64url")), + decipher.final(), + ]).toString("utf8"); + } catch { + throw new CapletsError("CONFIG_INVALID", "Vault encrypted record could not be decrypted."); + } +} + +export function parseEncryptedRecord(record: unknown): VaultEncryptedRecord { + if (!record || typeof record !== "object" || Array.isArray(record)) { + throw new CapletsError("CONFIG_INVALID", "Vault encrypted record must be an object."); + } + const value = record as Record; + if (value.version !== 1 || value.algorithm !== "aes-256-gcm") { + throw new CapletsError("CONFIG_INVALID", "Vault encrypted record version is unsupported."); + } + if ( + typeof value.nonce !== "string" || + typeof value.ciphertext !== "string" || + typeof value.authTag !== "string" || + typeof value.valueBytes !== "number" || + typeof value.createdAt !== "string" || + typeof value.updatedAt !== "string" + ) { + throw new CapletsError("CONFIG_INVALID", "Vault encrypted record is malformed."); + } + return value as VaultEncryptedRecord; +} diff --git a/packages/core/src/vault/index.ts b/packages/core/src/vault/index.ts new file mode 100644 index 00000000..cfed79a4 --- /dev/null +++ b/packages/core/src/vault/index.ts @@ -0,0 +1,291 @@ +import { Buffer } from "node:buffer"; +import { existsSync, readdirSync } from "node:fs"; +import { basename, join } from "node:path"; +import { CapletsError } from "../errors"; +import { defaultStateBaseDir } from "../config/paths"; +import { + decryptVaultValue, + encryptVaultValue, + parseEncryptedRecord, + type VaultEncryptedRecord, +} from "./crypto"; +import { ensureVaultKey, loadVaultKey, validateVaultKeyName, vaultKeySourceStatus } from "./keys"; +import { deleteFile, ensurePrivateDir, readJsonFile, writePrivateFileAtomic } from "./store"; +import { filterVaultGrants, sameOrigin, upsertVaultGrant, normalizeVaultGrant } from "./access"; +import { + VAULT_MAX_VALUE_BYTES, + type VaultAccessGrant, + type VaultAccessGrantFilter, + type VaultAccessGrantInput, + type VaultConfigOrigin, + type VaultDeleteStatus, + type VaultKeySourceStatus, + type VaultResolvedGrant, + type VaultValueStatus, +} from "./types"; + +export { + VAULT_MAX_VALUE_BYTES, + validateVaultKeyName, + type VaultAccessGrant, + type VaultAccessGrantFilter, + type VaultAccessGrantInput, + type VaultConfigOrigin, + type VaultDeleteStatus, + type VaultKeySourceStatus, + type VaultResolvedGrant, + type VaultValueStatus, +}; + +type FileVaultStoreOptions = { + root?: string | undefined; + env?: Record | undefined; +}; + +type SetOptions = { + force?: boolean | undefined; + now?: Date | undefined; +}; + +export class FileVaultStore { + readonly root: string; + readonly env: Record; + readonly paths: { + keyFile: string; + valuesDir: string; + grantsFile: string; + }; + + constructor(options: FileVaultStoreOptions = {}) { + this.root = options.root ?? join(defaultStateBaseDir(options.env), "caplets", "vault"); + this.env = options.env ?? process.env; + this.paths = { + keyFile: join(this.root, "vault-key"), + valuesDir: join(this.root, "values"), + grantsFile: join(this.root, "access-grants.json"), + }; + } + + valuePath(key: string): string { + return join(this.paths.valuesDir, `${encodeURIComponent(validateVaultKeyName(key))}.json`); + } + + set(key: string, value: string, options: SetOptions = {}): VaultValueStatus { + const normalizedKey = validateVaultKeyName(key); + const valueBytes = Buffer.byteLength(value, "utf8"); + if (valueBytes > VAULT_MAX_VALUE_BYTES) { + throw new CapletsError( + "REQUEST_INVALID", + `Vault values must be ${VAULT_MAX_VALUE_BYTES} bytes or smaller.`, + ); + } + + const path = this.valuePath(normalizedKey); + const existing = this.loadValueRecord(normalizedKey); + if (existing && !options.force) { + throw new CapletsError("CONFIG_EXISTS", `Vault key ${normalizedKey} already exists.`); + } + + ensurePrivateDir(this.root); + ensurePrivateDir(this.paths.valuesDir); + const encrypted = encryptVaultValue({ + plaintext: value, + key: ensureVaultKey({ keyFile: this.paths.keyFile, env: this.env }), + now: options.now ?? new Date(), + ...(existing ? { existing } : {}), + }); + writePrivateFileAtomic(path, `${JSON.stringify(encrypted, null, 2)}\n`); + return this.statusForRecord(normalizedKey, encrypted); + } + + getStatus(key: string): VaultValueStatus { + const normalizedKey = validateVaultKeyName(key); + const record = this.loadValueRecord(normalizedKey); + return record + ? this.statusForRecord(normalizedKey, record) + : { key: normalizedKey, present: false }; + } + + listValues(): VaultValueStatus[] { + if (!existsSync(this.paths.valuesDir)) return []; + return readdirSync(this.paths.valuesDir) + .filter((entry) => entry.endsWith(".json")) + .map((entry) => decodeURIComponent(basename(entry, ".json"))) + .map((key) => this.getStatus(key)) + .filter((status) => status.present) + .sort((left, right) => left.key.localeCompare(right.key)); + } + + resolveValue(key: string): string { + const normalizedKey = validateVaultKeyName(key); + const record = this.loadValueRecord(normalizedKey); + if (!record) { + throw new CapletsError("CONFIG_INVALID", `Vault key ${normalizedKey} is missing.`); + } + return decryptVaultValue(record, loadVaultKey({ keyFile: this.paths.keyFile, env: this.env })); + } + + delete(key: string): VaultDeleteStatus { + const normalizedKey = validateVaultKeyName(key); + const deleted = deleteFile(this.valuePath(normalizedKey)); + return { + key: normalizedKey, + deleted, + grantsRetained: this.listAccess({ storedKey: normalizedKey }).length, + }; + } + + keySourceStatus(): VaultKeySourceStatus { + return vaultKeySourceStatus({ keyFile: this.paths.keyFile, env: this.env }); + } + + grantAccess(input: VaultAccessGrantInput): VaultAccessGrant { + const next = normalizeVaultGrant(input); + const grants = upsertVaultGrant(this.loadAccessGrants(), input); + this.saveAccessGrants(grants); + return grants.find( + (grant) => + grant.storedKey === next.storedKey && + grant.referenceName === next.referenceName && + grant.capletId === next.capletId && + sameOrigin(grant.origin, next.origin), + ) as VaultAccessGrant; + } + + listAccess(filter: VaultAccessGrantFilter = {}): VaultAccessGrant[] { + return filterVaultGrants(this.loadAccessGrants(), filter); + } + + revokeAccess(filter: VaultAccessGrantFilter): VaultAccessGrant[] { + const removed = this.listAccess(filter); + if (removed.length === 0) return []; + const removedKeys = new Set(removed.map(accessGrantIdentity)); + const remaining = this.loadAccessGrants().filter( + (grant) => !removedKeys.has(accessGrantIdentity(grant)), + ); + this.saveAccessGrants(remaining); + return removed; + } + + resolveGrantedValue(input: { + referenceName: string; + capletId: string; + origin: VaultConfigOrigin; + }): VaultResolvedGrant { + const referenceName = validateVaultKeyName(input.referenceName); + const grant = this.listAccess({ + referenceName, + capletId: input.capletId, + origin: input.origin, + })[0]; + if (!grant) { + return { + reason: "ungranted", + referenceName, + capletId: input.capletId, + origin: input.origin, + }; + } + if (!existsSync(this.valuePath(grant.storedKey))) { + return { + reason: "missing", + storedKey: grant.storedKey, + referenceName, + capletId: input.capletId, + origin: input.origin, + }; + } + return { storedKey: grant.storedKey, value: this.resolveValue(grant.storedKey) }; + } + + private loadValueRecord(key: string): VaultEncryptedRecord | undefined { + const path = this.valuePath(key); + if (!existsSync(path)) return undefined; + let raw: unknown; + try { + raw = readJsonFile(path, {}); + } catch { + throw new CapletsError("CONFIG_INVALID", `Vault value record for ${key} is not valid JSON.`); + } + return parseEncryptedRecord(raw); + } + + private statusForRecord(key: string, record: VaultEncryptedRecord): VaultValueStatus { + return { + key, + present: true, + valueBytes: record.valueBytes, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; + } + + private loadAccessGrants(): VaultAccessGrant[] { + let raw: unknown; + try { + raw = readJsonFile(this.paths.grantsFile, []); + } catch { + throw new CapletsError("CONFIG_INVALID", "Vault access grants file is not valid JSON."); + } + if (!Array.isArray(raw)) { + throw new CapletsError("CONFIG_INVALID", "Vault access grants file must contain an array."); + } + return raw.map(parseStoredGrant); + } + + private saveAccessGrants(grants: VaultAccessGrant[]): void { + ensurePrivateDir(this.root); + writePrivateFileAtomic(this.paths.grantsFile, `${JSON.stringify(grants, null, 2)}\n`); + } +} + +function accessGrantIdentity(grant: VaultAccessGrant): string { + return [ + grant.storedKey, + grant.referenceName, + grant.capletId, + grant.origin.kind, + grant.origin.path, + ].join("\0"); +} + +function parseStoredGrant(value: unknown): VaultAccessGrant { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new CapletsError("CONFIG_INVALID", "Vault access grant must be an object."); + } + const record = value as Record; + if ( + typeof record.storedKey !== "string" || + typeof record.referenceName !== "string" || + typeof record.capletId !== "string" || + typeof record.createdAt !== "string" || + typeof record.updatedAt !== "string" || + !record.origin || + typeof record.origin !== "object" || + Array.isArray(record.origin) + ) { + throw new CapletsError("CONFIG_INVALID", "Vault access grant is malformed."); + } + const originRecord = record.origin as Record; + if ( + typeof originRecord.kind !== "string" || + typeof originRecord.path !== "string" || + !["global-config", "global-file", "project-config", "project-file"].includes(originRecord.kind) + ) { + throw new CapletsError("CONFIG_INVALID", "Vault access grant origin is malformed."); + } + const normalized = normalizeVaultGrant({ + storedKey: record.storedKey, + referenceName: record.referenceName, + capletId: record.capletId, + origin: { + kind: originRecord.kind as VaultConfigOrigin["kind"], + path: originRecord.path, + }, + }); + return { + ...normalized, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} diff --git a/packages/core/src/vault/keys.ts b/packages/core/src/vault/keys.ts new file mode 100644 index 00000000..1e13636e --- /dev/null +++ b/packages/core/src/vault/keys.ts @@ -0,0 +1,142 @@ +import { Buffer } from "node:buffer"; +import { chmodSync, existsSync, readFileSync, statSync } from "node:fs"; +import { dirname } from "node:path"; +import { randomBytes } from "node:crypto"; +import { CapletsError } from "../errors"; +import { ensurePrivateDir, writePrivateFileAtomic } from "./store"; +import type { VaultKeySourceStatus } from "./types"; + +const VAULT_KEY_PATTERN = /^[A-Z_][A-Z0-9_]{0,127}$/; +const KEY_FILE_PREFIX = "caplets-vault-key-v1."; +const KEY_BYTES = 32; + +export function validateVaultKeyName(name: string): string { + if (!VAULT_KEY_PATTERN.test(name)) { + throw new CapletsError( + "REQUEST_INVALID", + "Vault key names must match ^[A-Z_][A-Z0-9_]{0,127}$", + ); + } + return name; +} + +export function loadVaultKey(input: { + keyFile: string; + env?: Record | undefined; +}): Buffer { + const envKey = input.env?.CAPLETS_ENCRYPTION_KEY; + if (envKey !== undefined) return decodeExactKey(envKey, "CAPLETS_ENCRYPTION_KEY"); + + const status = vaultKeySourceStatus(input); + if (!status.available) { + const reason = "reason" in status ? status.reason : "invalid"; + throw new CapletsError("CONFIG_INVALID", `Vault key source is unavailable: ${reason}`); + } + return parseKeyFile(readFileSync(input.keyFile, "utf8")); +} + +export function ensureVaultKey(input: { + keyFile: string; + env?: Record | undefined; +}): Buffer { + const envKey = input.env?.CAPLETS_ENCRYPTION_KEY; + if (envKey !== undefined) return decodeExactKey(envKey, "CAPLETS_ENCRYPTION_KEY"); + + if (!existsSync(input.keyFile)) { + ensurePrivateDir(dirname(input.keyFile)); + const encoded = randomBytes(KEY_BYTES).toString("base64url"); + writePrivateFileAtomic(input.keyFile, `${KEY_FILE_PREFIX}${encoded}\n`); + try { + chmodSync(input.keyFile, 0o600); + } catch { + // Best effort on platforms without POSIX permissions. + } + } + return loadVaultKey(input); +} + +export function vaultKeySourceStatus(input: { + keyFile: string; + env?: Record | undefined; +}): VaultKeySourceStatus { + const envKey = input.env?.CAPLETS_ENCRYPTION_KEY; + if (envKey !== undefined) { + try { + decodeExactKey(envKey, "CAPLETS_ENCRYPTION_KEY"); + return { available: true, source: "env" }; + } catch { + return { available: false, source: "env", reason: "invalid" }; + } + } + + if (!existsSync(input.keyFile)) { + return { available: false, source: "file", reason: "missing", keyFile: input.keyFile }; + } + let mode: number; + try { + mode = statSync(input.keyFile).mode; + } catch (error) { + return unavailableKeyFileStatus(input.keyFile, error); + } + if (process.platform !== "win32" && (mode & 0o077) !== 0) { + return { + available: false, + source: "file", + reason: "wrong-permissions", + keyFile: input.keyFile, + }; + } + let contents: string; + try { + contents = readFileSync(input.keyFile, "utf8"); + } catch (error) { + return unavailableKeyFileStatus(input.keyFile, error); + } + try { + parseKeyFile(contents); + return { available: true, source: "file", keyFile: input.keyFile }; + } catch (error) { + const reason = + error instanceof CapletsError && error.message.includes("unsupported") + ? "unsupported-version" + : "invalid"; + return { available: false, source: "file", reason, keyFile: input.keyFile }; + } +} + +function unavailableKeyFileStatus(keyFile: string, error: unknown): VaultKeySourceStatus { + const code = + error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + return { + available: false, + source: "file", + reason: code === "ENOENT" ? "missing" : "unreadable", + keyFile, + }; +} + +function parseKeyFile(contents: string): Buffer { + const trimmed = contents.trim(); + if (!trimmed.startsWith(KEY_FILE_PREFIX)) { + throw new CapletsError("CONFIG_INVALID", "Vault key file has an unsupported format version."); + } + return decodeExactKey(trimmed.slice(KEY_FILE_PREFIX.length), "Vault key file"); +} + +function decodeExactKey(encoded: string, label: string): Buffer { + let decoded: Buffer; + try { + decoded = Buffer.from(encoded, "base64url"); + } catch { + throw new CapletsError("REQUEST_INVALID", `${label} must be a base64url-encoded 32-byte key.`); + } + if ( + decoded.length !== KEY_BYTES || + decoded.toString("base64url") !== encoded.replace(/=+$/u, "") + ) { + throw new CapletsError("REQUEST_INVALID", `${label} must be a base64url-encoded 32-byte key.`); + } + return decoded; +} diff --git a/packages/core/src/vault/store.ts b/packages/core/src/vault/store.ts new file mode 100644 index 00000000..6be3563e --- /dev/null +++ b/packages/core/src/vault/store.ts @@ -0,0 +1,42 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; + +export function ensurePrivateDir(path: string): void { + mkdirSync(path, { recursive: true, mode: 0o700 }); + try { + chmodSync(path, 0o700); + } catch { + // Best effort on platforms without POSIX permissions. + } +} + +export function writePrivateFileAtomic(path: string, contents: string): void { + ensurePrivateDir(dirname(path)); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, contents, { mode: 0o600 }); + try { + chmodSync(tempPath, 0o600); + } catch { + // Best effort on platforms without POSIX permissions. + } + renameSync(tempPath, path); +} + +export function readJsonFile(path: string, fallback: T): T { + if (!existsSync(path)) return fallback; + return JSON.parse(readFileSync(path, "utf8")) as T; +} + +export function deleteFile(path: string): boolean { + if (!existsSync(path)) return false; + rmSync(path, { force: true }); + return true; +} diff --git a/packages/core/src/vault/types.ts b/packages/core/src/vault/types.ts new file mode 100644 index 00000000..1f4e9887 --- /dev/null +++ b/packages/core/src/vault/types.ts @@ -0,0 +1,72 @@ +import type { ConfigSourceKind } from "../config"; + +export const VAULT_MAX_VALUE_BYTES = 64 * 1024; + +export type VaultConfigOrigin = { + kind: ConfigSourceKind; + path: string; +}; + +export type VaultKeySourceStatus = + | { available: true; source: "env"; keyFile?: undefined } + | { available: true; source: "file"; keyFile: string } + | { + available: false; + source: "env" | "file"; + reason: "missing" | "invalid" | "unreadable" | "wrong-permissions" | "unsupported-version"; + keyFile?: string | undefined; + }; + +export type VaultValueStatus = { + key: string; + present: boolean; + valueBytes?: number | undefined; + createdAt?: string | undefined; + updatedAt?: string | undefined; +}; + +export type VaultAccessGrant = { + storedKey: string; + referenceName: string; + capletId: string; + origin: VaultConfigOrigin; + createdAt: string; + updatedAt: string; +}; + +export type VaultAccessGrantInput = { + storedKey: string; + referenceName: string; + capletId: string; + origin: VaultConfigOrigin; + now?: Date | undefined; +}; + +export type VaultAccessGrantFilter = { + storedKey?: string | undefined; + referenceName?: string | undefined; + capletId?: string | undefined; + origin?: VaultConfigOrigin | undefined; +}; + +export type VaultResolvedGrant = + | { storedKey: string; value: string } + | { + reason: "ungranted"; + referenceName: string; + capletId: string; + origin: VaultConfigOrigin; + } + | { + reason: "missing"; + storedKey: string; + referenceName: string; + capletId: string; + origin: VaultConfigOrigin; + }; + +export type VaultDeleteStatus = { + key: string; + deleted: boolean; + grantsRetained: number; +}; diff --git a/packages/core/test/caplet-sets.test.ts b/packages/core/test/caplet-sets.test.ts index 74904bd3..7c4dead0 100644 --- a/packages/core/test/caplet-sets.test.ts +++ b/packages/core/test/caplet-sets.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { CapletSetManager } from "../src/caplet-sets"; import { parseConfig } from "../src/config"; import { ServerRegistry } from "../src/registry"; +import { FileVaultStore } from "../src/vault"; describe("CapletSetManager", () => { const dirs: string[] = []; @@ -117,6 +118,58 @@ describe("CapletSetManager", () => { ]); }); + it("resolves Vault references in nested child Caplets", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-set-vault-")); + dirs.push(dir); + const authDir = join(dir, "auth"); + const childConfigPath = join(dir, "child.json"); + writeFileSync( + childConfigPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub child Caplet.", + command: process.execPath, + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }), + ); + const store = new FileVaultStore({ root: join(authDir, "vault") }); + store.set("GH_TOKEN", "nested_secret"); + store.grantAccess({ + storedKey: "GH_TOKEN", + referenceName: "GH_TOKEN", + capletId: "github", + origin: { kind: "global-config", path: childConfigPath }, + }); + const config = parseConfig({ + capletSets: { + nested: { + name: "Nested Caplets", + description: "Expose child Caplets through a nested collection.", + configPath: childConfigPath, + toolCacheTtlMs: 0, + }, + }, + }); + const caplet = config.capletSets.nested!; + const manager = new CapletSetManager(new ServerRegistry(config), { authDir }); + + const tools = await manager.listTools(caplet); + + expect(tools.map((tool) => tool.name)).toEqual(["github"]); + const child = ( + manager as unknown as { + children: Map; + } + ).children.get("nested"); + expect(child?.registry.config.mcpServers.github?.env).toEqual({ + GH_TOKEN: "nested_secret", + }); + }); + it("routes child Google Discovery Caplets through nested tool calls", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-set-google-discovery-")); dirs.push(dir); diff --git a/packages/core/test/catalog-vault.test.ts b/packages/core/test/catalog-vault.test.ts new file mode 100644 index 00000000..22d161c5 --- /dev/null +++ b/packages/core/test/catalog-vault.test.ts @@ -0,0 +1,34 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SECRET_REFERENCE_PATTERN = /\$env:([A-Z_][A-Z0-9_]*)|\$\{([A-Z_][A-Z0-9_]*)\}/g; +const SECRET_NAME_PATTERN = /(TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY|CREDENTIAL|PRIVATE_KEY)/u; + +describe("catalog Vault guardrails", () => { + it("keeps checked-in catalog secret-like references on Vault syntax", () => { + const root = join(import.meta.dirname, "../../..", "caplets"); + const violations: string[] = []; + + for (const path of markdownFiles(root)) { + const text = readFileSync(path, "utf8"); + for (const match of text.matchAll(SECRET_REFERENCE_PATTERN)) { + const name = match[1] ?? match[2] ?? ""; + if (SECRET_NAME_PATTERN.test(name)) { + violations.push(`${path}: use $vault:${name} instead of ${match[0]}`); + } + } + } + + expect(violations).toEqual([]); + }); +}); + +function markdownFiles(root: string): string[] { + const entries = readdirSync(root, { withFileTypes: true }); + return entries.flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return markdownFiles(path); + return entry.isFile() && entry.name.endsWith(".md") ? [path] : []; + }); +} diff --git a/packages/core/test/cli-remote.test.ts b/packages/core/test/cli-remote.test.ts index cb55b541..96a60faa 100644 --- a/packages/core/test/cli-remote.test.ts +++ b/packages/core/test/cli-remote.test.ts @@ -3,11 +3,14 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runCli } from "../src/cli"; +import { HOSTED_CLOUD_AUTH_SCOPES } from "../src/cloud-auth/types"; import { CapletsEngine } from "../src/engine"; import { normalizeRemoteProfileHostUrl } from "../src/remote/options"; +import { createRemoteProfileStore } from "../src/remote/profile-store"; import { remoteProfileKey } from "../src/remote/profiles"; import { createHttpServeApp, type CapletsHttpApp } from "../src/serve/http"; import type { HttpServeOptions } from "../src/serve/options"; +import { FileVaultStore } from "../src/vault"; const dirs: string[] = []; @@ -232,6 +235,163 @@ describe("remote CLI routing", () => { ]); }); + it("routes Vault set and remote grants through remote control without local mirroring", async () => { + const context = testContext("caplets-cli-remote-vault-"); + const requests: unknown[] = []; + const out: string[] = []; + const localState = join(context.projectConfigPath, "..", "..", "local-state"); + const fetch = vi.fn( + async (_url: Parameters[0], init?: RequestInit) => { + requests.push(JSON.parse(String(init?.body ?? "{}"))); + return Response.json({ + ok: true, + result: { key: "GH_TOKEN_REMOTE", present: true, valueBytes: 20 }, + }); + }, + ); + + await runCli( + ["vault", "set", "GH_TOKEN_REMOTE", "--remote", "--grant", "github", "--as", "GH_TOKEN"], + { + env: { ...remoteEnv(context), XDG_STATE_HOME: localState }, + fetch, + readStdin: async () => "remote_cli_secret\n", + writeOut: (value) => out.push(value), + }, + ); + + expect(requests).toEqual([ + { + command: "vault_set", + arguments: { + name: "GH_TOKEN_REMOTE", + value: "remote_cli_secret", + force: false, + grant: "github", + referenceName: "GH_TOKEN", + }, + }, + ]); + expect(out.join("")).toContain("Set remote Vault key GH_TOKEN_REMOTE."); + expect(out.join("")).not.toContain("remote_cli_secret"); + expect( + new FileVaultStore({ env: { XDG_STATE_HOME: localState } }).getStatus("GH_TOKEN_REMOTE") + .present, + ).toBe(false); + }); + + it("prints remote Vault set JSON when --json is passed", async () => { + const context = testContext("caplets-cli-remote-vault-json-"); + const out: string[] = []; + const fetch = vi.fn(async () => + Response.json({ + ok: true, + result: { key: "GH_TOKEN_REMOTE", present: true, valueBytes: 20 }, + }), + ); + + await runCli(["vault", "set", "GH_TOKEN_REMOTE", "--remote", "--json"], { + env: remoteEnv(context), + fetch, + readStdin: async () => "remote_cli_secret\n", + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toEqual({ + key: "GH_TOKEN_REMOTE", + present: true, + valueBytes: 20, + }); + expect(out.join("")).not.toContain("remote_cli_secret"); + }); + + it("routes Vault access grant to hosted Cloud when remote mode selects Cloud", async () => { + const context = testContext("caplets-cli-cloud-vault-grant-"); + await createRemoteProfileStore({ authDir: context.authDir }).saveCloudProfile({ + hostUrl: "https://cloud.caplets.dev", + workspaceId: "ws_1", + workspaceSlug: "team", + clientLabel: "Cloud Test", + credentials: { + accessToken: "cloud-access-token", + refreshToken: "cloud-refresh-token", + expiresAt: "2999-01-01T00:00:00.000Z", + scope: [...HOSTED_CLOUD_AUTH_SCOPES], + tokenType: "Bearer", + }, + }); + const requests: unknown[] = []; + const out: string[] = []; + const fetch = vi.fn(async (url: Parameters[0], init?: RequestInit) => { + requests.push({ + url: String(url), + authorization: new Headers(init?.headers).get("authorization"), + method: init?.method, + body: init?.body, + }); + return Response.json({ + storedKey: "GH_TOKEN", + referenceName: "GH_TOKEN", + capletId: "github", + }); + }); + + await runCli(["vault", "access", "grant", "GH_TOKEN", "github", "--remote"], { + env: { + ...remoteEnv(context), + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + fetch, + authDir: context.authDir, + writeOut: (value) => out.push(value), + }); + + expect(requests).toEqual([ + { + url: "https://cloud.caplets.dev/api/workspaces/team/vault/access/GH_TOKEN/github", + authorization: "Bearer cloud-access-token", + method: "PUT", + body: JSON.stringify({ referenceName: "GH_TOKEN" }), + }, + ]); + expect(out.join("")).toContain("Granted Vault key GH_TOKEN to github as GH_TOKEN."); + }); + + it("formats hosted Cloud Vault access grants without origin metadata", async () => { + const context = testContext("caplets-cli-cloud-vault-access-list-"); + await createRemoteProfileStore({ authDir: context.authDir }).saveCloudProfile({ + hostUrl: "https://cloud.caplets.dev", + workspaceId: "ws_1", + workspaceSlug: "team", + clientLabel: "Cloud Test", + credentials: { + accessToken: "cloud-access-token", + refreshToken: "cloud-refresh-token", + expiresAt: "2999-01-01T00:00:00.000Z", + scope: [...HOSTED_CLOUD_AUTH_SCOPES], + tokenType: "Bearer", + }, + }); + const out: string[] = []; + const fetch = vi.fn(async () => + Response.json([{ storedKey: "GH_TOKEN", referenceName: "GH_TOKEN", capletId: "github" }]), + ); + + await runCli(["vault", "access", "list", "--remote"], { + env: { + ...remoteEnv(context), + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + fetch, + authDir: context.authDir, + writeOut: (value) => out.push(value), + }); + + expect(out.join("")).toBe("GH_TOKEN -> github:GH_TOKEN\n"); + }); + it("falls back to remote list when local overlay loading warns", async () => { const context = testContext("caplets-cli-remote-list-overlay-invalid-"); const out: string[] = []; diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index 11b52959..7fc634d3 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -17,6 +17,7 @@ import { loadConfig, parseConfig } from "../src/config"; import type { CapletsError } from "../src/errors"; import { readTokenBundle, writeTokenBundle } from "../src/auth"; import { FileRemoteProfileStore } from "../src/remote/profile-store"; +import { FileVaultStore } from "../src/vault"; describe("cli init", () => { const originalMode = process.env.CAPLETS_MODE; @@ -154,6 +155,259 @@ describe("cli init", () => { ); }); + it("sets, gets, updates, lists, and deletes local Vault values without accidental reveal", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-cli-")); + const env = { ...process.env, XDG_STATE_HOME: join(dir, "state") }; + const out: string[] = []; + try { + await runCli(["vault", "set", "GH_TOKEN"], { + env, + readStdin: async () => "vault_cli_secret\n", + writeOut: (value) => out.push(value), + }); + await runCli(["vault", "get", "GH_TOKEN"], { env, writeOut: (value) => out.push(value) }); + await runCli(["vault", "list", "--json"], { env, writeOut: (value) => out.push(value) }); + + expect(out.join("")).toContain("Set Vault key GH_TOKEN."); + expect(out.join("")).toContain("GH_TOKEN"); + expect(out.join("")).not.toContain("vault_cli_secret"); + + await expect( + runCli(["vault", "set", "GH_TOKEN"], { + env, + readStdin: async () => "updated_secret\n", + writeOut: () => {}, + }), + ).rejects.toMatchObject({ code: "CONFIG_EXISTS" } satisfies Partial); + + await runCli(["vault", "set", "GH_TOKEN", "--force"], { + env, + readStdin: async () => "updated_secret\n", + writeOut: (value) => out.push(value), + }); + await runCli(["vault", "get", "GH_TOKEN", "--show"], { + env, + writeOut: (value) => out.push(value), + }); + await runCli(["vault", "delete", "GH_TOKEN"], { + env, + writeOut: (value) => out.push(value), + }); + + expect(out.join("")).toContain("updated_secret"); + expect(out.join("")).toContain("Deleted Vault key GH_TOKEN."); + const afterDeleteOutput = out.at(-1) ?? ""; + expect(afterDeleteOutput).not.toContain("updated_secret"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects local Vault set without non-argv input in noninteractive execution", async () => { + await expect( + runCli(["vault", "set", "GH_TOKEN"], { + env: { ...process.env, XDG_STATE_HOME: mkdtempSync(join(tmpdir(), "caplets-vault-cli-")) }, + writeOut: () => {}, + writeErr: () => {}, + }), + ).rejects.toMatchObject({ code: "REQUEST_INVALID" } satisfies Partial); + }); + + it("rolls back a new local Vault value when set-and-grant fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-cli-grant-fail-")); + const configPath = join(dir, "config.json"); + const env = { + ...process.env, + CAPLETS_CONFIG: configPath, + XDG_STATE_HOME: join(dir, "state"), + }; + try { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub access.", + command: "github-mcp", + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }), + ); + + await expect( + runCli(["vault", "set", "GH_TOKEN", "--grant", "missing-caplet"], { + env, + readStdin: async () => "orphaned_secret\n", + writeOut: () => {}, + }), + ).rejects.toMatchObject({ code: "SERVER_NOT_FOUND" } satisfies Partial); + + expect(new FileVaultStore({ env }).getStatus("GH_TOKEN")).toEqual({ + key: "GH_TOKEN", + present: false, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("restores the previous local Vault value when force set-and-grant fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-cli-grant-force-fail-")); + const configPath = join(dir, "config.json"); + const env = { + ...process.env, + CAPLETS_CONFIG: configPath, + XDG_STATE_HOME: join(dir, "state"), + }; + try { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub access.", + command: "github-mcp", + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }), + ); + const store = new FileVaultStore({ env }); + store.set("GH_TOKEN", "original_secret"); + + await expect( + runCli(["vault", "set", "GH_TOKEN", "--force", "--grant", "missing-caplet"], { + env, + readStdin: async () => "replacement_secret\n", + writeOut: () => {}, + }), + ).rejects.toMatchObject({ code: "SERVER_NOT_FOUND" } satisfies Partial); + + expect(store.resolveValue("GH_TOKEN")).toBe("original_secret"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("manages local Vault access grants against the active config source", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-cli-access-")); + const configPath = join(dir, "config.json"); + const env = { + ...process.env, + CAPLETS_CONFIG: configPath, + XDG_STATE_HOME: join(dir, "state"), + }; + const out: string[] = []; + try { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + "github-personal": { + name: "GitHub Personal", + description: "Personal GitHub access.", + transport: "http", + url: "https://api.githubcopilot.com/mcp", + auth: { type: "bearer", token: "$vault:GH_TOKEN" }, + }, + }, + }), + ); + + await runCli( + ["vault", "set", "GH_TOKEN_PERSONAL", "--grant", "github-personal", "--as", "GH_TOKEN"], + { + env, + readStdin: async () => "personal_secret\n", + writeOut: (value) => out.push(value), + }, + ); + await runCli(["vault", "access", "list", "--json"], { + env, + writeOut: (value) => out.push(value), + }); + await runCli(["vault", "access", "list", "--caplet", "github-personal", "--json"], { + env, + writeOut: (value) => out.push(value), + }); + await runCli( + ["vault", "access", "revoke", "GH_TOKEN_PERSONAL", "github-personal", "--as", "GH_TOKEN"], + { + env, + writeOut: (value) => out.push(value), + }, + ); + + const store = new FileVaultStore({ env }); + expect( + store.resolveGrantedValue({ + referenceName: "GH_TOKEN", + capletId: "github-personal", + origin: { kind: "global-config", path: configPath }, + }), + ).toMatchObject({ reason: "ungranted" }); + expect(out.join("")).toContain( + "Granted Vault key GH_TOKEN_PERSONAL to github-personal as GH_TOKEN.", + ); + expect(out.join("")).toContain('"referenceName": "GH_TOKEN"'); + expect(out.join("")).toContain('"capletId": "github-personal"'); + expect(out.join("")).not.toContain("personal_secret"); + expect(out.join("")).toContain("Revoked 1 Vault access grant."); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("grants Vault access when unrelated missing env refs would quarantine the Caplet", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-cli-access-env-")); + const configPath = join(dir, "config.json"); + const missingEnvName = "CAPLETS_TEST_MISSING_GRANT_ENV"; + const env = { + ...process.env, + CAPLETS_CONFIG: configPath, + XDG_STATE_HOME: join(dir, "state"), + [missingEnvName]: undefined, + }; + try { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub access with missing runtime env.", + command: "github-mcp", + env: { + GH_TOKEN: "$vault:GH_TOKEN", + OTHER: `$env:${missingEnvName}`, + }, + }, + }, + }), + ); + + await runCli(["vault", "access", "grant", "GH_TOKEN", "github"], { + env, + writeOut: () => undefined, + }); + + const store = new FileVaultStore({ env }); + expect(store.listAccess({ storedKey: "GH_TOKEN", capletId: "github" })).toEqual([ + expect.objectContaining({ + storedKey: "GH_TOKEN", + referenceName: "GH_TOKEN", + capletId: "github", + origin: { kind: "global-config", path: configPath }, + }), + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("rejects empty self-hosted remote login code from stdin", async () => { const fetchStub = vi.fn(); diff --git a/packages/core/test/cloud-client.test.ts b/packages/core/test/cloud-client.test.ts index 49195019..1410ad97 100644 --- a/packages/core/test/cloud-client.test.ts +++ b/packages/core/test/cloud-client.test.ts @@ -101,4 +101,120 @@ describe("CapletsCloudClient", () => { }), ); }); + + it("sends Vault set requests to the selected workspace contract without redacting the request body", async () => { + const fetch = vi.fn(async () => + Response.json({ key: "GH_TOKEN", present: true, valueBytes: 12 }), + ); + const client = new CapletsCloudClient({ + baseUrl: new URL("https://cloud.caplets.dev"), + accessToken: "token", + fetch, + }); + + await expect( + client.setVaultValue({ + workspace: "team", + name: "GH_TOKEN", + value: "cloud_secret", + force: true, + grant: "github", + referenceName: "GH_TOKEN", + }), + ).resolves.toEqual({ key: "GH_TOKEN", present: true, valueBytes: 12 }); + + expect(fetch).toHaveBeenCalledWith( + new URL("https://cloud.caplets.dev/api/workspaces/team/vault/values/GH_TOKEN"), + expect.objectContaining({ + method: "PUT", + headers: expect.any(Headers), + body: JSON.stringify({ + value: "cloud_secret", + force: true, + grant: "github", + referenceName: "GH_TOKEN", + }), + }), + ); + }); + + it("sends explicit human context for Cloud Vault reveal requests", async () => { + const fetch = vi.fn(async () => Response.json({ key: "GH_TOKEN", value: "cloud_secret" })); + const client = new CapletsCloudClient({ + baseUrl: new URL("https://cloud.caplets.dev"), + accessToken: "token", + fetch, + }); + + await expect( + client.getVaultValue({ workspace: "team", name: "GH_TOKEN", reveal: true }), + ).resolves.toEqual({ key: "GH_TOKEN", value: "cloud_secret" }); + + expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ method: "GET" })); + const revealCalls = fetch.mock.calls as unknown as Array<[URL, RequestInit?]>; + const revealUrl = new URL(String(revealCalls[0]?.[0])); + expect(revealUrl.pathname).toBe("/api/workspaces/team/vault/values/GH_TOKEN"); + expect(revealUrl.searchParams.get("reveal")).toBe("true"); + expect(revealUrl.searchParams.get("revealContext")).toBe("human-cli"); + }); + + it("sends Vault access management requests to the selected workspace contract", async () => { + const fetch = vi.fn(async (url: Parameters[0]) => { + const pathname = new URL(String(url)).pathname; + if (pathname.endsWith("/vault/access")) { + return Response.json([{ storedKey: "GH_TOKEN", capletId: "github" }]); + } + return Response.json({ + storedKey: "GH_TOKEN", + referenceName: "GH_TOKEN", + capletId: "github", + }); + }); + const client = new CapletsCloudClient({ + baseUrl: new URL("https://cloud.caplets.dev/ws/ian"), + accessToken: "token", + fetch, + }); + + await client.grantVaultAccess({ + workspace: "team", + name: "GH_TOKEN", + capletId: "github", + referenceName: "GH_TOKEN", + }); + await client.listVaultAccess({ workspace: "team", name: "GH_TOKEN", capletId: "github" }); + await client.revokeVaultAccess({ + workspace: "team", + name: "GH_TOKEN", + capletId: "github", + referenceName: "GH_TOKEN", + }); + + expect(fetch).toHaveBeenNthCalledWith( + 1, + new URL("https://cloud.caplets.dev/ws/ian/api/workspaces/team/vault/access/GH_TOKEN/github"), + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ referenceName: "GH_TOKEN" }), + }), + ); + expect(fetch).toHaveBeenNthCalledWith( + 2, + expect.any(URL), + expect.objectContaining({ method: "GET" }), + ); + const accessCalls = fetch.mock.calls as unknown as Array<[URL, RequestInit?]>; + const listUrl = new URL(String(accessCalls[1]?.[0])); + expect(listUrl.pathname).toBe("/ws/ian/api/workspaces/team/vault/access"); + expect(listUrl.searchParams.get("name")).toBe("GH_TOKEN"); + expect(listUrl.searchParams.get("capletId")).toBe("github"); + expect(fetch).toHaveBeenNthCalledWith( + 3, + expect.any(URL), + expect.objectContaining({ method: "DELETE" }), + ); + const revokeUrl = new URL(String(accessCalls[2]?.[0])); + expect(revokeUrl.pathname).toBe("/ws/ian/api/workspaces/team/vault/access/GH_TOKEN/github"); + expect(revokeUrl.searchParams.get("referenceName")).toBe("GH_TOKEN"); + }); }); diff --git a/packages/core/test/code-mode-api.test.ts b/packages/core/test/code-mode-api.test.ts index c84928b0..d1d60c96 100644 --- a/packages/core/test/code-mode-api.test.ts +++ b/packages/core/test/code-mode-api.test.ts @@ -81,6 +81,25 @@ describe("Code Mode Caplets API", () => { }); }); + it("does not expose Vault reveal operations on Code Mode caplet handles", () => { + const api = createCodeModeCapletsApi({ + service: service([ + { + caplet: "github", + toolName: "caplets__github", + title: "GitHub", + description: "GitHub repo operations.", + promptGuidance: [], + }, + ]), + }); + const github = api.github as CodeModeCapletHandle & Record; + + expect(Object.keys(github).filter((key) => key.toLowerCase().includes("vault"))).toEqual([]); + expect(github.getVault).toBeUndefined(); + expect(github.revealVault).toBeUndefined(); + }); + it("treats unavailable backend checks as expected readiness failures", async () => { const native = service([ { diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 9df5ef2e..8cf29abe 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -20,12 +20,16 @@ import { defaultCompletionCacheDir, loadConfig, loadLocalOverlayConfigWithSources, + loadLocalRuntimeConfig, loadConfigWithSources, parseConfig, + vaultBootstrapResolver, + type ConfigVaultResolver, } from "../src/config"; import { listCaplets } from "../src/cli/inspection"; import { CapletsError } from "../src/errors"; import { ServerRegistry } from "../src/registry"; +import { FileVaultStore } from "../src/vault"; describe("config", () => { const originalEnv = process.env.EXAMPLE_TOKEN; @@ -1075,6 +1079,542 @@ describe("config", () => { } }); + it("resolves Vault refs through an explicit parse resolver while preserving public metadata", () => { + const calls: Parameters[0][] = []; + const resolver: ConfigVaultResolver = (reference) => { + calls.push(reference); + return { storedKey: reference.referenceName, value: "resolved_vault_secret" }; + }; + const origin = { + kind: "global-file" as const, + path: "/home/ian/.config/caplets/github/CAPLET.md", + }; + + const config = parseConfig( + { + mcpServers: { + github: { + name: "GitHub $vault:GH_TOKEN", + description: "Literal ${vault:GH_TOKEN}", + tags: ["$vault:GH_TOKEN"], + command: "github-mcp", + env: { + GH_TOKEN: "$vault:GH_TOKEN", + GH_TOKEN_BRACED: "${vault:GH_TOKEN}", + }, + }, + }, + }, + { + sources: { github: origin }, + vaultResolver: resolver, + }, + ); + + expect(config.mcpServers.github?.name).toBe("GitHub $vault:GH_TOKEN"); + expect(config.mcpServers.github?.description).toBe("Literal ${vault:GH_TOKEN}"); + expect(config.mcpServers.github?.tags).toEqual(["$vault:GH_TOKEN"]); + expect(config.mcpServers.github?.env).toEqual({ + GH_TOKEN: "resolved_vault_secret", + GH_TOKEN_BRACED: "resolved_vault_secret", + }); + expect(calls).toEqual([ + { + referenceName: "GH_TOKEN", + capletId: "github", + origin, + path: "mcpServers.github.env.GH_TOKEN", + }, + { + referenceName: "GH_TOKEN", + capletId: "github", + origin, + path: "mcpServers.github.env.GH_TOKEN_BRACED", + }, + ]); + }); + + it("leaves Vault refs literal when bare parseConfig has no resolver", () => { + const config = parseConfig({ + mcpServers: { + github: { + name: "GitHub", + description: "Uses a Vault ref.", + command: "github-mcp", + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }); + + expect(config.mcpServers.github?.env).toEqual({ GH_TOKEN: "$vault:GH_TOKEN" }); + }); + + it("stops bare Vault references at key-name boundaries", () => { + const config = parseConfig( + { + mcpServers: { + github: { + name: "GitHub", + description: "Uses Vault refs inside longer strings.", + command: "github-mcp", + env: { + API_URL: "$vault:BASE_URL/v1", + QUERY: "token=$vault:TOKEN&x=1", + }, + }, + }, + }, + { + sources: { + github: { + kind: "global-config", + path: "/tmp/caplets/config.json", + }, + }, + vaultResolver: (reference) => ({ + storedKey: reference.referenceName, + value: reference.referenceName === "BASE_URL" ? "https://api.example.com" : "secret", + }), + }, + ); + + expect(config.mcpServers.github?.env).toEqual({ + API_URL: "https://api.example.com/v1", + QUERY: "token=secret&x=1", + }); + }); + + it("quarantines malformed Vault-looking references", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-invalid-ref-")); + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(userRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "Uses a malformed Vault ref.", + command: "github-mcp", + env: { GH_TOKEN: "$vault:gh_token" }, + }, + }, + }), + ); + + const { config, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + ); + + expect(config.mcpServers.github).toBeUndefined(); + expect(warnings[0]?.message).toContain("invalid-key-source"); + expect(warnings[0]?.message).toContain("gh_token"); + expect(warnings[0]?.message).toContain("caplets doctor"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("resolves strict loader Vault refs with the configured Vault store by default", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-strict-resolve-")); + const originalStateHome = process.env.XDG_STATE_HOME; + try { + process.env.XDG_STATE_HOME = join(dir, "state"); + const userConfigPath = join(dir, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + oauth: { + name: "OAuth", + description: "Remote OAuth downstream server.", + transport: "http", + url: "https://example.com/mcp", + auth: { + type: "oauth2", + tokenUrl: "https://example.com/token", + clientId: "client", + clientSecret: "$vault:CLIENT_SECRET", + }, + }, + }, + }), + ); + const store = new FileVaultStore(); + store.set("CLIENT_SECRET", "resolved-client-secret"); + store.grantAccess({ + storedKey: "CLIENT_SECRET", + referenceName: "CLIENT_SECRET", + capletId: "oauth", + origin: { kind: "global-config", path: userConfigPath }, + }); + + const config = loadConfig(userConfigPath, projectConfigPath); + + expect(config.mcpServers.oauth?.auth).toMatchObject({ + type: "oauth2", + clientSecret: "resolved-client-secret", + }); + } finally { + if (originalStateHome === undefined) { + delete process.env.XDG_STATE_HOME; + } else { + process.env.XDG_STATE_HOME = originalStateHome; + } + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("allows bootstrap inspection loaders to read Vault-backed URI auth fields", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-strict-uri-loader-")); + try { + const userConfigPath = join(dir, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + oauth: { + name: "OAuth", + description: "Remote OAuth downstream server.", + transport: "http", + url: "https://example.com/mcp", + auth: { + type: "oidc", + issuer: "$vault:ISSUER", + redirectUri: "$vault:REDIRECT_URI", + clientId: "client", + }, + }, + }, + }), + ); + + const { config } = loadConfigWithSources(userConfigPath, projectConfigPath, { + vaultResolver: vaultBootstrapResolver, + }); + + expect(config.mcpServers.oauth?.auth).toMatchObject({ + type: "oidc", + issuer: "https://caplets.local/vault-placeholder", + redirectUri: "https://caplets.local/vault-placeholder", + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("validates local overlay config after resolving Vault refs in URL fields", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-vault-url-")); + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(userRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + remote: { + name: "Remote", + description: "Uses a Vault-backed URL.", + transport: "http", + url: "$vault:REMOTE_URL", + }, + }, + }), + ); + + const { config, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + { + vaultResolver: (reference) => ({ + storedKey: reference.referenceName, + value: "https://example.com/mcp", + }), + }, + ); + + expect(config.mcpServers.remote?.url).toBe("https://example.com/mcp"); + expect(warnings).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("quarantines Markdown Caplet files with unresolved Vault-backed URL fields", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-markdown-vault-url-")); + try { + const userConfigPath = join(dir, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "remote.md"), + [ + "---", + "name: Remote", + "description: Uses a Vault backed remote URL.", + "mcpServer:", + " transport: http", + " url: $vault:REMOTE_URL", + "---", + "# Remote", + ].join("\n"), + ); + + const { config, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + { + vaultResolver: (reference) => ({ + reason: "ungranted", + referenceName: reference.referenceName, + capletId: reference.capletId, + origin: reference.origin, + }), + }, + ); + + expect(config.mcpServers.remote).toBeUndefined(); + expect(warnings[0]?.message).toContain("caplets vault access grant REMOTE_URL remote"); + expect(warnings[0]?.message).not.toContain("invalid frontmatter"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("quarantines ungranted Vault refs in local overlays without dropping valid siblings", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-vault-ungranted-")); + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(userRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "Uses a Vault token.", + command: "github-mcp", + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + healthy: { + name: "Healthy Local", + description: "A useful healthy downstream server.", + command: "healthy-server", + }, + }, + }), + ); + + const { config, sources, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + { + vaultResolver: (reference) => ({ + reason: "ungranted", + referenceName: reference.referenceName, + capletId: reference.capletId, + origin: reference.origin, + }), + }, + ); + + expect(config.mcpServers.healthy?.command).toBe("healthy-server"); + expect(config.mcpServers.github).toBeUndefined(); + expect(sources.healthy).toEqual({ kind: "global-config", path: userConfigPath }); + expect(sources.github).toBeUndefined(); + expect(warnings).toEqual([ + expect.objectContaining({ + kind: "global-config", + path: userConfigPath, + recoverable: true, + message: expect.stringContaining("Vault key GH_TOKEN"), + }), + ]); + expect(warnings[0]?.message).toContain("ungranted"); + expect(warnings[0]?.message).toContain("mcpServers.github.env.GH_TOKEN"); + expect(warnings[0]?.message).toContain("caplets vault access grant GH_TOKEN github"); + expect(warnings[0]?.message).not.toContain("resolved_vault_secret"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reports remapped missing Vault keys with the stored key repair command", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-remap-warning-")); + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(userRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub tools.", + command: "github-mcp", + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }), + ); + + const { warnings } = loadLocalOverlayConfigWithSources(userConfigPath, projectConfigPath, { + vaultResolver: (reference) => ({ + reason: "missing", + storedKey: "GH_TOKEN_PERSONAL", + referenceName: reference.referenceName, + capletId: reference.capletId, + origin: reference.origin, + }), + }); + + expect(warnings[0]?.message).toContain("Vault key GH_TOKEN_PERSONAL"); + expect(warnings[0]?.message).toContain("caplets vault set GH_TOKEN_PERSONAL"); + expect(warnings[0]?.message).not.toContain("caplets vault set GH_TOKEN "); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("targets remote Vault recovery commands when requested", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-remote-warning-")); + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(userRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub tools.", + command: "github-mcp", + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }), + ); + + const { warnings } = loadLocalOverlayConfigWithSources(userConfigPath, projectConfigPath, { + vaultRecoveryTarget: "remote", + vaultResolver: (reference) => ({ + reason: "ungranted", + referenceName: reference.referenceName, + capletId: reference.capletId, + origin: reference.origin, + }), + }); + + expect(warnings[0]?.message).toContain("caplets vault access grant GH_TOKEN github --remote"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reports invalid Vault key sources without suggesting set or grant", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-key-source-warning-")); + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(userRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub tools.", + command: "github-mcp", + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }), + ); + + const { warnings } = loadLocalOverlayConfigWithSources(userConfigPath, projectConfigPath, { + vaultResolver: (reference) => ({ + reason: "invalid-key-source", + referenceName: reference.referenceName, + capletId: reference.capletId, + origin: reference.origin, + }), + }); + + expect(warnings[0]?.message).toContain("invalid-key-source"); + expect(warnings[0]?.message).toContain("caplets doctor"); + expect(warnings[0]?.message).not.toContain("caplets vault set"); + expect(warnings[0]?.message).not.toContain("caplets vault access grant"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("allows strict inspection loaders to read Vault-backed URL fields with bootstrap values", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-strict-loader-")); + try { + const userConfigPath = join(dir, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + remote: { + name: "Remote", + description: "Remote MCP server.", + transport: "http", + url: "$vault:REMOTE_URL", + }, + }, + }), + ); + + const { config } = loadConfigWithSources(userConfigPath, projectConfigPath, { + vaultResolver: vaultBootstrapResolver, + }); + + expect(config.mcpServers.remote?.url).toBe("https://caplets.local/vault-placeholder"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reports invalid runtime config sources before falling back to missing config", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-runtime-invalid-source-")); + try { + const userConfigPath = join(dir, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(dir, { recursive: true }); + writeFileSync(userConfigPath, "{"); + + expect(() => loadLocalRuntimeConfig(userConfigPath, projectConfigPath)).toThrow( + expect.objectContaining({ + code: "CONFIG_INVALID", + message: expect.stringContaining("not valid JSON"), + }), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("preserves local overlay source and shadow metadata", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-shadows-")); try { @@ -1149,142 +1689,147 @@ describe("config", () => { }); it("keeps repository example Caplets loadable", () => { - const originalGithubToken = process.env.GH_TOKEN; - process.env.GH_TOKEN = "test-github-token"; - try { - const examples = loadCapletFiles(join(import.meta.dirname, "../../..", "caplets")); - - const config = parseConfig(examples); - - expect(config.mcpServers.context7).toMatchObject({ - server: "context7", - name: "Context7 Documentation", - command: "context7-mcp", - setup: { - commands: [ - { - label: "Install Context7 MCP", - command: "npm", - args: ["install", "-g", "@upstash/context7-mcp"], - }, - ], - }, - }); - expect(config.mcpServers.github).toMatchObject({ - server: "github", - name: "GitHub", - transport: "http", - url: "https://api.githubcopilot.com/mcp", - auth: { type: "bearer", token: "test-github-token" }, - }); - expect(config.mcpServers.linear).toMatchObject({ - server: "linear", - name: "Linear", - transport: "http", - url: "https://mcp.linear.app/mcp", - auth: { type: "oauth2" }, - }); - expect(config.mcpServers["ast-grep"]).toMatchObject({ - server: "ast-grep", - name: "ast-grep", - command: "ast-grep-mcp", - setup: { - verify: [ - { - label: "Check ast-grep MCP", - command: "ast-grep-mcp", - args: ["--help"], - }, - ], + const examples = loadCapletFiles(join(import.meta.dirname, "../../..", "caplets")); + + const config = parseConfig(examples, { + sources: { + github: { + kind: "global-file", + path: join(import.meta.dirname, "../../..", "caplets", "github", "CAPLET.md"), }, - }); - expect(config.httpApis.osv).toMatchObject({ - server: "osv", - name: "OSV Vulnerabilities", - baseUrl: "https://api.osv.dev", - auth: { type: "none" }, - actions: { - query_package_version: { - method: "POST", - path: "/v1/query", - jsonBody: { - package: { - name: "$input.name", - ecosystem: "$input.ecosystem", - }, - version: "$input.version", - }, + }, + vaultResolver: (reference) => { + if (reference.referenceName !== "GH_TOKEN") { + throw new Error( + `Unexpected Vault reference in repository fixture: ${reference.referenceName}`, + ); + } + return { storedKey: reference.referenceName, value: "test-github-token" }; + }, + }); + + expect(config.mcpServers.context7).toMatchObject({ + server: "context7", + name: "Context7 Documentation", + command: "context7-mcp", + setup: { + commands: [ + { + label: "Install Context7 MCP", + command: "npm", + args: ["install", "-g", "@upstash/context7-mcp"], }, - get_vulnerability: { - method: "GET", - path: "/v1/vulns/{id}", + ], + }, + }); + expect(config.mcpServers.github).toMatchObject({ + server: "github", + name: "GitHub", + transport: "http", + url: "https://api.githubcopilot.com/mcp", + auth: { type: "bearer", token: "test-github-token" }, + }); + expect(config.mcpServers.linear).toMatchObject({ + server: "linear", + name: "Linear", + transport: "http", + url: "https://mcp.linear.app/mcp", + auth: { type: "oauth2" }, + }); + expect(config.mcpServers["ast-grep"]).toMatchObject({ + server: "ast-grep", + name: "ast-grep", + command: "ast-grep-mcp", + setup: { + verify: [ + { + label: "Check ast-grep MCP", + command: "ast-grep-mcp", + args: ["--help"], }, - }, - }); - expect(config.openapiEndpoints.npm).toMatchObject({ - server: "npm", - name: "npm Registry", - specUrl: "https://raw.githubusercontent.com/npm/api-documentation/main/api/base.yaml", - auth: { type: "none" }, - }); - expect(config.openapiEndpoints.pypi).toMatchObject({ - server: "pypi", - name: "PyPI", - specPath: expect.stringMatching(/caplets[/\\]pypi[/\\]pypi\.openapi\.yaml$/), - auth: { type: "none" }, - }); - expect(config.mcpServers.deepwiki).toMatchObject({ - server: "deepwiki", - name: "DeepWiki", - transport: "http", - url: "https://mcp.deepwiki.com/mcp", - auth: { type: "none" }, - }); - expect(config.mcpServers.sourcegraph).toMatchObject({ - server: "sourcegraph", - name: "Sourcegraph", - transport: "http", - url: "https://sourcegraph.com/.api/mcp", - auth: { type: "oauth2" }, - }); - expect(config.mcpServers.playwright).toMatchObject({ - server: "playwright", - name: "Playwright", - command: "playwright-mcp", - args: ["--headless"], - setup: { - commands: [ - { - label: "Install Playwright MCP", - command: "npm", - args: ["install", "-g", "@playwright/mcp@0.0.75"], - }, - { - label: "Install Chromium browser", - command: "npx", - args: ["playwright", "install", "chromium"], + ], + }, + }); + expect(config.httpApis.osv).toMatchObject({ + server: "osv", + name: "OSV Vulnerabilities", + baseUrl: "https://api.osv.dev", + auth: { type: "none" }, + actions: { + query_package_version: { + method: "POST", + path: "/v1/query", + jsonBody: { + package: { + name: "$input.name", + ecosystem: "$input.ecosystem", }, - ], + version: "$input.version", + }, }, - }); - expect(config.mcpServers.lsp).toMatchObject({ - server: "lsp", - name: "LSP", - command: "npx", - args: ["-y", "language-server-mcp"], - }); - expect(config.capletSets["coding-agent-toolkit"]).toMatchObject({ - server: "coding-agent-toolkit", - name: "Coding Agent Toolkit", - capletsRoot: expect.stringMatching(/caplets[/\\]coding-agent-toolkit[/\\]caplets$/), - }); - } finally { - if (originalGithubToken === undefined) { - delete process.env.GH_TOKEN; - } else { - process.env.GH_TOKEN = originalGithubToken; - } - } + get_vulnerability: { + method: "GET", + path: "/v1/vulns/{id}", + }, + }, + }); + expect(config.openapiEndpoints.npm).toMatchObject({ + server: "npm", + name: "npm Registry", + specUrl: "https://raw.githubusercontent.com/npm/api-documentation/main/api/base.yaml", + auth: { type: "none" }, + }); + expect(config.openapiEndpoints.pypi).toMatchObject({ + server: "pypi", + name: "PyPI", + specPath: expect.stringMatching(/caplets[/\\]pypi[/\\]pypi\.openapi\.yaml$/), + auth: { type: "none" }, + }); + expect(config.mcpServers.deepwiki).toMatchObject({ + server: "deepwiki", + name: "DeepWiki", + transport: "http", + url: "https://mcp.deepwiki.com/mcp", + auth: { type: "none" }, + }); + expect(config.mcpServers.sourcegraph).toMatchObject({ + server: "sourcegraph", + name: "Sourcegraph", + transport: "http", + url: "https://sourcegraph.com/.api/mcp", + auth: { type: "oauth2" }, + }); + expect(config.mcpServers.playwright).toMatchObject({ + server: "playwright", + name: "Playwright", + command: "playwright-mcp", + args: ["--headless"], + setup: { + commands: [ + { + label: "Install Playwright MCP", + command: "npm", + args: ["install", "-g", "@playwright/mcp@0.0.75"], + }, + { + label: "Install Chromium browser", + command: "npx", + args: ["playwright", "install", "chromium"], + }, + ], + }, + }); + expect(config.mcpServers.lsp).toMatchObject({ + server: "lsp", + name: "LSP", + command: "npx", + args: ["-y", "language-server-mcp"], + }); + expect(config.capletSets["coding-agent-toolkit"]).toMatchObject({ + server: "coding-agent-toolkit", + name: "Coding Agent Toolkit", + capletsRoot: expect.stringMatching(/caplets[/\\]coding-agent-toolkit[/\\]caplets$/), + }); }); it("keeps repository Caplet reference files linked from CAPLET.md", () => { diff --git a/packages/core/test/doctor-cli.test.ts b/packages/core/test/doctor-cli.test.ts index e6204500..65202fdb 100644 --- a/packages/core/test/doctor-cli.test.ts +++ b/packages/core/test/doctor-cli.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { CloudAuthStore } from "../src/cloud-auth/store"; import { runCli } from "../src/cli"; +import { doctorJsonReport } from "../src/cli/doctor"; import { hostedCredentials, tempCloudAuthPath } from "./fixtures/cloud-auth"; import { FileRemoteProfileStore } from "../src/remote/profile-store"; @@ -148,6 +151,114 @@ describe("caplets doctor", () => { }); }); + it("reports unresolved local Vault references with repair commands", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-doctor-vault-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + const plainOut: string[] = []; + try { + mkdirSync(dir, { recursive: true }); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub tools.", + command: "github-mcp", + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }), + ); + + await runCli(["doctor", "--json"], { + env: { + CAPLETS_CONFIG: configPath, + XDG_STATE_HOME: join(dir, "state"), + }, + writeOut: (value) => out.push(value), + }); + + const report = JSON.parse(out.join("")); + expect(report.vault).toMatchObject({ + ok: false, + issues: [ + expect.objectContaining({ + key: "GH_TOKEN", + capletId: "github", + target: "global", + recoveryCommand: "caplets vault access grant GH_TOKEN github", + }), + ], + }); + expect(JSON.stringify(report.vault)).not.toContain("secret"); + + await runCli(["doctor"], { + env: { + CAPLETS_CONFIG: configPath, + XDG_STATE_HOME: join(dir, "state"), + }, + writeOut: (value) => plainOut.push(value), + }); + expect(plainOut.join("")).toContain( + "github: ungranted GH_TOKEN (caplets vault access grant GH_TOKEN github)", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("honors DoctorOptions.cwd when checking project Vault references", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-doctor-cwd-")); + const configPath = join(dir, "config.json"); + const projectRoot = join(dir, "project"); + const projectCapletDir = join(projectRoot, ".caplets", "github"); + try { + mkdirSync(projectCapletDir, { recursive: true }); + writeFileSync(configPath, "{}"); + writeFileSync( + join(projectCapletDir, "CAPLET.md"), + [ + "---", + "name: GitHub", + "description: GitHub tools.", + "mcpServer:", + " transport: http", + " url: https://api.githubcopilot.com/mcp", + " auth:", + " type: bearer", + " token: $vault:GH_TOKEN", + "---", + "", + "# GitHub", + "", + ].join("\n"), + ); + + const report = await doctorJsonReport({ + cwd: projectRoot, + env: { + CAPLETS_CONFIG: configPath, + XDG_STATE_HOME: join(dir, "state"), + }, + }); + + expect(report.vault).toMatchObject({ + ok: false, + issues: [ + expect.objectContaining({ + capletId: "github", + key: "GH_TOKEN", + recoveryCommand: "caplets vault access grant GH_TOKEN github", + }), + ], + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("emits JSON diagnostics with separate server, remote, binding, sync, daemon, and auth sections", async () => { const out: string[] = []; @@ -160,12 +271,13 @@ describe("caplets doctor", () => { writeOut: (value) => out.push(value), }); - expect(JSON.parse(out.join(""))).toMatchObject({ + const report = JSON.parse(out.join("")); + expect(report).toMatchObject({ server: { configured: true }, remote: { configured: true }, projectBinding: { state: "not_attached" }, sync: { state: "idle" }, - daemon: { running: false }, + daemon: { running: expect.any(Boolean) }, remoteLogin: { configured: true, authenticated: false }, exposure: { ok: true }, codeMode: { diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index 71c2937d..e542686e 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -3,18 +3,107 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CapletsEngine } from "../src/engine"; +import { FileVaultStore } from "../src/vault"; describe("CapletsEngine", () => { const dirs: string[] = []; const engines: CapletsEngine[] = []; + const originalStateHome = process.env.XDG_STATE_HOME; afterEach(async () => { await Promise.all(engines.splice(0).map((engine) => engine.close())); + if (originalStateHome === undefined) { + delete process.env.XDG_STATE_HOME; + } else { + process.env.XDG_STATE_HOME = originalStateHome; + } for (const dir of dirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); + it("uses the Vault-aware runtime loader by default", () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub access.", + command: process.execPath, + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }); + dirs.push(dir); + process.env.XDG_STATE_HOME = join(dir, "state"); + const store = new FileVaultStore(); + store.set("GH_TOKEN", "resolved_vault_secret"); + store.grantAccess({ + storedKey: "GH_TOKEN", + referenceName: "GH_TOKEN", + capletId: "github", + origin: { kind: "global-config", path: configPath }, + }); + + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + engines.push(engine); + + expect(engine.currentConfig().mcpServers.github?.env).toEqual({ + GH_TOKEN: "resolved_vault_secret", + }); + }); + + it("prints recoverable Vault quarantine warnings during startup", () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub access.", + command: process.execPath, + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }); + dirs.push(dir); + process.env.XDG_STATE_HOME = join(dir, "state"); + const errors: string[] = []; + + const engine = new CapletsEngine({ + configPath, + projectConfigPath, + watch: false, + writeErr: (value) => errors.push(value), + }); + engines.push(engine); + + expect(engine.currentConfig().mcpServers.github).toBeUndefined(); + expect(errors.join("")).toContain("Caplet github references"); + expect(errors.join("")).toContain("caplets vault access grant GH_TOKEN github"); + expect(errors.join("")).not.toContain("resolved_vault_secret"); + }); + + it("fails startup when no config sources exist", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-engine-missing-")); + dirs.push(dir); + + expect( + () => + new CapletsEngine({ + configPath: join(dir, "missing-user.json"), + projectConfigPath: join(dir, "project", ".caplets", "config.json"), + watch: false, + }), + ).toThrow("Caplets config not found"); + }); + + it("fails startup when config sources define no Caplets", () => { + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + + expect(() => new CapletsEngine({ configPath, projectConfigPath, watch: false })).toThrow( + "Caplets config must define at least one", + ); + }); + it("adds, updates, and removes enabled Caplets across successful reloads", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ mcpServers: { @@ -139,6 +228,34 @@ describe("CapletsEngine", () => { expect(errors.join("")).toContain("Caplets config reload failed"); }); + it("keeps last known-good config when config sources disappear", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + alpha: { + name: "Alpha", + description: "Search alpha project documents.", + command: process.execPath, + }, + }, + }); + dirs.push(dir); + const errors: string[] = []; + const engine = new CapletsEngine({ + configPath, + projectConfigPath, + watch: false, + writeErr: (value) => errors.push(value), + }); + engines.push(engine); + + rmSync(configPath); + + await expect(engine.reload()).resolves.toBe(false); + expect(engine.enabledServers().map((caplet) => caplet.server)).toEqual(["alpha"]); + expect(errors.join("")).toContain("Caplets config reload failed"); + expect(errors.join("")).toContain("Caplets config not found"); + }); + it("continues notifying reload listeners when one listener throws", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ mcpServers: { diff --git a/packages/core/test/native.test.ts b/packages/core/test/native.test.ts index 124a9c5a..2aebe875 100644 --- a/packages/core/test/native.test.ts +++ b/packages/core/test/native.test.ts @@ -9,6 +9,7 @@ import { nativeCapletToolName, nativeCapletsSystemGuidance, } from "../src/native"; +import { FileVaultStore } from "../src/vault"; const fixturesDir = fileURLToPath(new URL("fixtures", import.meta.url)); const tsxImport = import.meta.resolve("tsx"); @@ -16,6 +17,7 @@ const tsxImport = import.meta.resolve("tsx"); describe("native Caplets service", () => { const dirs: string[] = []; const originalMode = process.env.CAPLETS_MODE; + const originalStateHome = process.env.XDG_STATE_HOME; beforeEach(() => { process.env.CAPLETS_MODE = "local"; @@ -27,6 +29,11 @@ describe("native Caplets service", () => { } else { process.env.CAPLETS_MODE = originalMode; } + if (originalStateHome === undefined) { + delete process.env.XDG_STATE_HOME; + } else { + process.env.XDG_STATE_HOME = originalStateHome; + } for (const dir of dirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -126,6 +133,51 @@ describe("native Caplets service", () => { } }); + it("quarantines Vault-backed Caplets until the configured access grant exists", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + github: { + name: "GitHub", + description: "Inspect GitHub repository work.", + command: process.execPath, + env: { GH_TOKEN: "$vault:GH_TOKEN" }, + }, + }, + }); + dirs.push(dir); + process.env.XDG_STATE_HOME = join(dir, "state"); + + const ungranted = createNativeCapletsService({ configPath, projectConfigPath }); + try { + expect(ungranted.listTools().map((tool) => tool.caplet)).not.toContain("github"); + } finally { + await ungranted.close(); + } + + const store = new FileVaultStore(); + store.set("GH_TOKEN", "resolved_vault_secret"); + store.grantAccess({ + storedKey: "GH_TOKEN", + referenceName: "GH_TOKEN", + capletId: "github", + origin: { kind: "global-config", path: configPath }, + }); + + const granted = createNativeCapletsService({ configPath, projectConfigPath }); + try { + expect(granted.listTools()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + caplet: "github", + toolName: "caplets__github", + }), + ]), + ); + } finally { + await granted.close(); + } + }); + it("lists direct native operation tools with the caplets double-underscore prefix", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ httpApis: { diff --git a/packages/core/test/remote-control-client.test.ts b/packages/core/test/remote-control-client.test.ts index 4a4cb64f..d6236071 100644 --- a/packages/core/test/remote-control-client.test.ts +++ b/packages/core/test/remote-control-client.test.ts @@ -135,6 +135,33 @@ describe("RemoteControlClient", () => { } }); + it("redacts operation-scoped Vault values from remote errors", async () => { + const client = new RemoteControlClient({ + baseUrl: new URL("https://example.com/caplets"), + requestInit: {}, + fetch: async () => + Response.json({ + ok: false, + error: { + code: "DOWNSTREAM_TOOL_ERROR", + message: "runtime echoed exact value unlabeled_remote_secret_123", + }, + }), + }); + + try { + await client.request("vault_set", { + name: "GH_TOKEN", + value: "unlabeled_remote_secret_123", + }); + throw new Error("expected request to fail"); + } catch (error) { + expect(error).toBeInstanceOf(CapletsError); + expect((error as CapletsError).message).not.toContain("unlabeled_remote_secret_123"); + expect((error as CapletsError).message).toContain("[REDACTED]"); + } + }); + it("redacts password, client secret, and api key forms from remote error messages", async () => { const client = new RemoteControlClient({ baseUrl: new URL("https://example.com/caplets"), diff --git a/packages/core/test/remote-control-dispatch.test.ts b/packages/core/test/remote-control-dispatch.test.ts index d7bd533a..250fa92c 100644 --- a/packages/core/test/remote-control-dispatch.test.ts +++ b/packages/core/test/remote-control-dispatch.test.ts @@ -13,6 +13,7 @@ vi.mock("@modelcontextprotocol/sdk/client/auth", async (importOriginal) => ({ import { readTokenBundle, writeTokenBundle } from "../src/auth"; import { RemoteAuthFlowStore } from "../src/remote-control/auth-flow"; import { dispatchRemoteCliRequest } from "../src/remote-control/dispatch"; +import { FileVaultStore } from "../src/vault"; const dirs: string[] = []; @@ -153,6 +154,144 @@ describe("dispatchRemoteCliRequest", () => { expect(JSON.stringify(response)).toContain("[REDACTED]"); }); + it("executes Vault operations against server-side state", async () => { + const context = testContext(); + const authDir = join(context.tempRoot, "auth"); + writeFileSync( + context.configPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub", + description: "GitHub tools.", + transport: "http", + url: "https://api.githubcopilot.com/mcp", + auth: { type: "bearer", token: "$vault:GH_TOKEN" }, + }, + }, + }), + ); + + const set = await dispatchRemoteCliRequest( + { + command: "vault_set", + arguments: { + name: "GH_TOKEN_REMOTE", + value: "remote_dispatch_secret", + grant: "github", + referenceName: "GH_TOKEN", + force: false, + }, + }, + { ...context, authDir }, + ); + const list = await dispatchRemoteCliRequest( + { command: "vault_access_list", arguments: {} }, + { ...context, authDir }, + ); + const inspect = await dispatchRemoteCliRequest( + { + command: "inspect", + arguments: { caplet: "github", request: { operation: "inspect" } }, + }, + { ...context, authDir }, + ); + + const store = new FileVaultStore({ root: join(authDir, "vault") }); + expect(set).toMatchObject({ ok: true, result: { key: "GH_TOKEN_REMOTE", present: true } }); + expect(JSON.stringify(set)).not.toContain("remote_dispatch_secret"); + expect(store.resolveValue("GH_TOKEN_REMOTE")).toBe("remote_dispatch_secret"); + expect(list).toMatchObject({ + ok: true, + result: [ + expect.objectContaining({ + storedKey: "GH_TOKEN_REMOTE", + referenceName: "GH_TOKEN", + capletId: "github", + }), + ], + }); + expect(JSON.stringify(list)).not.toContain("remote_dispatch_secret"); + expect(inspect).toMatchObject({ + ok: true, + result: { + structuredContent: { + result: { id: "github", backend: { type: "mcp" }, name: "GitHub" }, + }, + }, + }); + }); + + it("does not retain a remote Vault value when set-and-grant fails", async () => { + const context = testContext(); + const authDir = join(context.tempRoot, "auth"); + + const response = await dispatchRemoteCliRequest( + { + command: "vault_set", + arguments: { + name: "GH_TOKEN", + value: "remote_orphan_secret", + grant: "missing_caplet", + force: false, + }, + }, + { ...context, authDir }, + ); + + const store = new FileVaultStore({ root: join(authDir, "vault") }); + expect(response).toMatchObject({ ok: false }); + expect(store.getStatus("GH_TOKEN")).toEqual({ key: "GH_TOKEN", present: false }); + expect(JSON.stringify(response)).not.toContain("remote_orphan_secret"); + }); + + it("restores the previous remote Vault value when force set-and-grant fails", async () => { + const context = testContext(); + const authDir = join(context.tempRoot, "auth"); + const store = new FileVaultStore({ root: join(authDir, "vault") }); + store.set("GH_TOKEN", "original_secret"); + + const response = await dispatchRemoteCliRequest( + { + command: "vault_set", + arguments: { + name: "GH_TOKEN", + value: "replacement_secret", + grant: "missing_caplet", + force: true, + }, + }, + { ...context, authDir }, + ); + + expect(response).toMatchObject({ ok: false }); + expect(store.resolveValue("GH_TOKEN")).toBe("original_secret"); + expect(JSON.stringify(response)).not.toContain("replacement_secret"); + }); + + it("rejects forged remote Vault raw reveal requests", async () => { + const context = testContext(); + const authDir = join(context.tempRoot, "auth"); + new FileVaultStore({ root: join(authDir, "vault") }).set("GH_TOKEN", "remote_secret"); + + const response = await dispatchRemoteCliRequest( + { + command: "vault_get", + arguments: { name: "GH_TOKEN", reveal: true, revealContext: "human-cli" }, + }, + { ...context, authDir }, + ); + + expect(response).toMatchObject({ + ok: false, + error: { + code: "REQUEST_INVALID", + message: "Self-hosted remote Vault reveal is not supported through remote control.", + }, + }); + expect(JSON.stringify(response)).not.toContain("remote_secret"); + }); + it("adds MCP Caplets to the server-side project Caplets root", async () => { const context = testContext(); diff --git a/packages/core/test/vault.test.ts b/packages/core/test/vault.test.ts new file mode 100644 index 00000000..df9f3755 --- /dev/null +++ b/packages/core/test/vault.test.ts @@ -0,0 +1,297 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { CapletsError } from "../src/errors"; +import { + FileVaultStore, + VAULT_MAX_VALUE_BYTES, + validateVaultKeyName, + type VaultConfigOrigin, +} from "../src/vault"; + +const tempDirs: string[] = []; +const origin: VaultConfigOrigin = { + kind: "global-file", + path: "/home/ian/.config/caplets/github/CAPLET.md", +}; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("Caplets Vault local store", () => { + it("rejects invalid Vault key names before mutations", () => { + const invalid = [ + "", + "gh_token", + "9TOKEN", + "GH/TOKEN", + "GH:TOKEN", + "$vault:GH_TOKEN", + "${vault:GH_TOKEN}", + "GH TOKEN", + "GH\tTOKEN", + "A".repeat(129), + ]; + + for (const name of invalid) { + expect(() => validateVaultKeyName(name)).toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, + ); + } + expect(validateVaultKeyName("GH_TOKEN_2")).toBe("GH_TOKEN_2"); + }); + + it("mints owner-only key material and stores encrypted values without plaintext", () => { + const dir = tempDir(); + const store = new FileVaultStore({ root: dir }); + + const status = store.set("GH_TOKEN", "plain_fixture_secret"); + + expect(status).toMatchObject({ + key: "GH_TOKEN", + present: true, + valueBytes: "plain_fixture_secret".length, + }); + expect(JSON.stringify(status)).not.toContain("plain_fixture_secret"); + expect(readFileSync(store.paths.keyFile, "utf8")).toMatch( + /^caplets-vault-key-v1\.[A-Za-z0-9_-]+\n$/, + ); + expect(readFileSync(store.valuePath("GH_TOKEN"), "utf8")).not.toContain("plain_fixture_secret"); + expect(store.resolveValue("GH_TOKEN")).toBe("plain_fixture_secret"); + + if (process.platform !== "win32") { + expect(statSync(dir).mode & 0o777).toBe(0o700); + expect(statSync(store.paths.keyFile).mode & 0o777).toBe(0o600); + expect(statSync(store.valuePath("GH_TOKEN")).mode & 0o777).toBe(0o600); + } + }); + + it("uses CAPLETS_ENCRYPTION_KEY when it decodes to exactly 32 bytes", () => { + const dir = tempDir(); + const key = Buffer.alloc(32, 7).toString("base64url"); + const store = new FileVaultStore({ + root: dir, + env: { CAPLETS_ENCRYPTION_KEY: key }, + }); + + store.set("GH_TOKEN", "env_key_secret"); + + expect(existsSync(store.paths.keyFile)).toBe(false); + expect(store.resolveValue("GH_TOKEN")).toBe("env_key_secret"); + }); + + it("fails closed for invalid key sources and tampered encrypted records", () => { + const invalidKeyStore = new FileVaultStore({ + root: tempDir(), + env: { CAPLETS_ENCRYPTION_KEY: "not-a-32-byte-key" }, + }); + + expect(() => invalidKeyStore.set("GH_TOKEN", "secret")).toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, + ); + + const dir = tempDir(); + const store = new FileVaultStore({ root: dir }); + store.set("GH_TOKEN", "tamper_secret"); + const envelope = JSON.parse(readFileSync(store.valuePath("GH_TOKEN"), "utf8")) as Record< + string, + unknown + >; + writeFileSync( + store.valuePath("GH_TOKEN"), + `${JSON.stringify({ ...envelope, ciphertext: "AAAA" }, null, 2)}\n`, + ); + + expect(() => store.resolveValue("GH_TOKEN")).toThrow( + expect.objectContaining({ code: "CONFIG_INVALID" }) as CapletsError, + ); + }); + + it("reports malformed Vault metadata through CapletsError", () => { + const dir = tempDir(); + const store = new FileVaultStore({ root: dir }); + store.set("GH_TOKEN", "secret"); + + writeFileSync(store.valuePath("GH_TOKEN"), "{"); + expect(() => store.getStatus("GH_TOKEN")).toThrow( + expect.objectContaining({ + code: "CONFIG_INVALID", + message: "Vault value record for GH_TOKEN is not valid JSON.", + }) as CapletsError, + ); + + const grantsStore = new FileVaultStore({ root: tempDir() }); + writeFileSync(grantsStore.paths.grantsFile, "{}\n"); + expect(() => grantsStore.listAccess()).toThrow( + expect.objectContaining({ + code: "CONFIG_INVALID", + message: "Vault access grants file must contain an array.", + }) as CapletsError, + ); + + writeFileSync(grantsStore.paths.grantsFile, "{"); + expect(() => grantsStore.listAccess()).toThrow( + expect.objectContaining({ + code: "CONFIG_INVALID", + message: "Vault access grants file is not valid JSON.", + }) as CapletsError, + ); + }); + + it("rejects overly broad minted key-file permissions on POSIX platforms", () => { + if (process.platform === "win32") return; + const dir = tempDir(); + const store = new FileVaultStore({ root: dir }); + store.set("GH_TOKEN", "secret"); + chmodSync(store.paths.keyFile, 0o644); + + expect(store.keySourceStatus()).toMatchObject({ + available: false, + reason: "wrong-permissions", + }); + expect(() => store.resolveValue("GH_TOKEN")).toThrow( + expect.objectContaining({ code: "CONFIG_INVALID" }) as CapletsError, + ); + }); + + it("lists grant metadata without raw values and scopes resolution by origin", () => { + const store = new FileVaultStore({ root: tempDir() }); + store.set("GH_TOKEN_PERSONAL", "personal_secret"); + store.grantAccess({ + storedKey: "GH_TOKEN_PERSONAL", + referenceName: "GH_TOKEN", + capletId: "github-personal", + origin, + now: new Date("2026-06-22T12:00:00.000Z"), + }); + + const grants = store.listAccess({ capletId: "github-personal" }); + + expect(grants).toEqual([ + { + storedKey: "GH_TOKEN_PERSONAL", + referenceName: "GH_TOKEN", + capletId: "github-personal", + origin, + createdAt: "2026-06-22T12:00:00.000Z", + updatedAt: "2026-06-22T12:00:00.000Z", + }, + ]); + expect(JSON.stringify(grants)).not.toContain("personal_secret"); + expect( + store.resolveGrantedValue({ + referenceName: "GH_TOKEN", + capletId: "github-personal", + origin, + }), + ).toEqual({ storedKey: "GH_TOKEN_PERSONAL", value: "personal_secret" }); + expect( + store.resolveGrantedValue({ + referenceName: "GH_TOKEN", + capletId: "github-personal", + origin: { ...origin, path: "/different/CAPLET.md" }, + }), + ).toEqual({ + reason: "ungranted", + referenceName: "GH_TOKEN", + capletId: "github-personal", + origin: { ...origin, path: "/different/CAPLET.md" }, + }); + }); + + it("replaces the stored key when granting the same Caplet reference again", () => { + const store = new FileVaultStore({ root: tempDir() }); + store.set("GH_TOKEN_PERSONAL", "personal_secret"); + store.set("GH_TOKEN_WORK", "work_secret"); + store.grantAccess({ + storedKey: "GH_TOKEN_PERSONAL", + referenceName: "GH_TOKEN", + capletId: "github", + origin, + now: new Date("2026-06-22T12:00:00.000Z"), + }); + + store.grantAccess({ + storedKey: "GH_TOKEN_WORK", + referenceName: "GH_TOKEN", + capletId: "github", + origin, + now: new Date("2026-06-22T13:00:00.000Z"), + }); + + expect(store.listAccess({ referenceName: "GH_TOKEN", capletId: "github", origin })).toEqual([ + expect.objectContaining({ + storedKey: "GH_TOKEN_WORK", + referenceName: "GH_TOKEN", + capletId: "github", + createdAt: "2026-06-22T12:00:00.000Z", + updatedAt: "2026-06-22T13:00:00.000Z", + }), + ]); + expect( + store.resolveGrantedValue({ referenceName: "GH_TOKEN", capletId: "github", origin }), + ).toEqual({ + storedKey: "GH_TOKEN_WORK", + value: "work_secret", + }); + }); + + it("overwrites only with force, preserves grants, and deletes without reveal", () => { + const store = new FileVaultStore({ root: tempDir() }); + store.set("GH_TOKEN", "first_secret"); + store.grantAccess({ + storedKey: "GH_TOKEN", + referenceName: "GH_TOKEN", + capletId: "github", + origin, + }); + + expect(() => store.set("GH_TOKEN", "second_secret")).toThrow( + expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError, + ); + const before = readFileSync(store.valuePath("GH_TOKEN"), "utf8"); + store.set("GH_TOKEN", "second_secret", { force: true }); + const after = readFileSync(store.valuePath("GH_TOKEN"), "utf8"); + + expect(after).not.toBe(before); + expect(store.resolveValue("GH_TOKEN")).toBe("second_secret"); + expect(store.listAccess({ storedKey: "GH_TOKEN" })).toHaveLength(1); + + const deleted = store.delete("GH_TOKEN"); + + expect(deleted).toEqual({ key: "GH_TOKEN", deleted: true, grantsRetained: 1 }); + expect(JSON.stringify(deleted)).not.toContain("second_secret"); + expect( + store.resolveGrantedValue({ referenceName: "GH_TOKEN", capletId: "github", origin }), + ).toMatchObject({ + reason: "missing", + storedKey: "GH_TOKEN", + }); + }); + + it("rejects values over 64 KiB before writing encrypted records", () => { + const store = new FileVaultStore({ root: tempDir() }); + const tooLarge = "x".repeat(VAULT_MAX_VALUE_BYTES + 1); + + expect(() => store.set("GH_TOKEN", tooLarge)).toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, + ); + expect(existsSync(store.valuePath("GH_TOKEN"))).toBe(false); + }); +}); + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "caplets-vault-")); + tempDirs.push(dir); + return dir; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d43a779..a5767ab6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 2.31.0(@types/node@25.9.4) '@cloudflare/workers-types': specifier: ^4.20260613.1 - version: 4.20260620.1 + version: 4.20260621.1 '@types/node': specifier: ^25.9.3 version: 25.9.4 @@ -28,7 +28,7 @@ importers: version: 9.1.7 lint-staged: specifier: ^17.0.7 - version: 17.0.7 + version: 17.0.8 oxfmt: specifier: ^0.54.0 version: 0.54.0 @@ -264,7 +264,7 @@ importers: version: link:../core '@opencode-ai/plugin': specifier: '>=1' - version: 1.17.8 + version: 1.17.9 devDependencies: '@jitl/quickjs-wasmfile-release-sync': specifier: ^0.32.0 @@ -292,10 +292,10 @@ importers: version: link:../core '@earendil-works/pi-coding-agent': specifier: '*' - version: 0.79.8(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + version: 0.79.9(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-tui': specifier: '*' - version: 0.79.8 + version: 0.79.9 devDependencies: '@jitl/quickjs-wasmfile-release-sync': specifier: ^0.32.0 @@ -689,8 +689,8 @@ packages: cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260620.1': - resolution: {integrity: sha512-WB81w9u1bAS7KcekpC7/nYhLpIXAEtgybso7XgGJV8CQKNkNPYcyjvICLdghOlDBi/9Ivk+f7NRckV2Bkq1bDg==} + '@cloudflare/workers-types@4.20260621.1': + resolution: {integrity: sha512-c4xrf4shZdDOK1ihh1UKzlS/3MDYiGThT/Oqr4Y3qR9NLCSNzHB7rt+Vk/LOp0ZSNjA+7WNJEQsOhpiQtpT2GA==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -703,22 +703,22 @@ packages: '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} - '@earendil-works/pi-agent-core@0.79.8': - resolution: {integrity: sha512-8m5fcqRpoGpq3QY0I/tFXROSTmPwBb1dAuzYZO3XYgjsdCokkRMAGRjA9P8s/UD6Jy9yy69lyE4H6sz/5A1TmQ==} + '@earendil-works/pi-agent-core@0.79.9': + resolution: {integrity: sha512-GsFbPR85nhncKoU9++fTKa11PzwUkAmkrKXo97dBOzi10Td72rVV6vmfxKjwPLHTzRbJMa0byr12YhODZ59yLA==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.79.8': - resolution: {integrity: sha512-ZpSwaD7oNpsjn9vtEatZQNT9PSdDJXi6rFeY5Qv+OHQGFDKlmcrfJE4ypm4SAc/fBECPs4Rdi3l+YjVtXYrkKw==} + '@earendil-works/pi-ai@0.79.9': + resolution: {integrity: sha512-fHmgNMONwCCE7bQAKbcz76sgm3iQuA7km1mpIc4H5xXd9+zhPh/faULz6ARkgjQE0EufHnfZPJY39+lNf8Sa9g==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.79.8': - resolution: {integrity: sha512-wr9oTS/yrwURDXnYrONQgFgV7QDlwslXL/rvKU5X7TRtrGxIhippsRApXqYlRwSeMjb2YzgHMfZ/kAhOqrzoFQ==} + '@earendil-works/pi-coding-agent@0.79.9': + resolution: {integrity: sha512-8TZ796Zn0NE4vmhxG9hv4ZtJDGJzhqMjlmFg8ZkUKxfqB7LJa4ums2jSJKtnyAZfAamN6VzqzN0A82RNDqv8Ag==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-tui@0.79.8': - resolution: {integrity: sha512-QerB+0wUc6eEO8MwvzOQGtzcsbwo6y8VvdxYU6vGcakz6ofJZWhrmwrknp1dCGx3bEtCf+siUIxEzkqvFCzIsg==} + '@earendil-works/pi-tui@0.79.9': + resolution: {integrity: sha512-XcqfoGyoX64OSMQklMR1vG2MRi1TCPSUERRCmPDvYKCK6LtZoBqJ6idNCLRklNYveCj4gHDOzOsLhGqn04jmYw==} engines: {node: '>=22.19.0'} '@emmetio/abbreviation@2.3.3': @@ -1676,8 +1676,8 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} - '@opencode-ai/plugin@1.17.8': - resolution: {integrity: sha512-pkmnYQz5d+xf0h6fAjgplSSJKLqgYKOXr+x6y40GRPdW+/IfndFkMGq7CDsG2SieGD84qv4zYDMyolGo06IMpw==} + '@opencode-ai/plugin@1.17.9': + resolution: {integrity: sha512-RvYsX2k90ew0P3c0R/Jrt6UOdA5CYf4GmYuREDKHx31GSJ25WKg9hrTzkcwCfvPPCpaBc53Qq6Fon9aWr+58ag==} peerDependencies: '@opentui/core': '>=0.3.4' '@opentui/keymap': '>=0.3.4' @@ -1690,8 +1690,8 @@ packages: '@opentui/solid': optional: true - '@opencode-ai/sdk@1.17.8': - resolution: {integrity: sha512-6MKmsj2ujZyL44jy+12dpwWYDYKPS9fUr+0wVQxaIlPYQ/eAt8T8T3QrybplJ5ZtHfZUX+esXZ02x2UYYm7oEw==} + '@opencode-ai/sdk@1.17.9': + resolution: {integrity: sha512-MHmXEpGPHkg14v1p+cUlIOUxd6DQdSElfau9nqY7tcDI0x5r4Y8D0dKXcyAh0Gc73ptaGW67Vg84nkcV6O27Pw==} '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} @@ -4049,8 +4049,8 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - lint-staged@17.0.7: - resolution: {integrity: sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==} + lint-staged@17.0.8: + resolution: {integrity: sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==} engines: {node: '>=22.22.1'} hasBin: true @@ -4357,8 +4357,8 @@ packages: multipasta@0.2.7: resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} - nanoid@3.3.13: - resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==} + nanoid@3.3.14: + resolution: {integrity: sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4569,8 +4569,8 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.5.0: - resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + path-expression-matcher@1.6.0: + resolution: {integrity: sha512-e5y7RCLHKjemsgQ4eqGJtPyr10ILz25HO7flzxhTV8bgvd5yHx98DGtCAtbVW9f2TqnYI/gEVZd+vz7snrdPTw==} engines: {node: '>=14.0.0'} path-key@3.1.1: @@ -5335,8 +5335,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@14.0.0: - resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true vary@1.1.2: @@ -6371,7 +6371,7 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260617.1': optional: true - '@cloudflare/workers-types@4.20260620.1': {} + '@cloudflare/workers-types@4.20260621.1': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -6381,9 +6381,9 @@ snapshots: '@dimforge/rapier3d-compat@0.12.0': {} - '@earendil-works/pi-agent-core@0.79.8(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.79.9(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.79.8(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.79.9(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -6395,7 +6395,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.79.8(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.79.9(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -6416,11 +6416,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.79.8(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.79.9(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.79.8(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.79.8(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.79.8 + '@earendil-works/pi-agent-core': 0.79.9(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.79.9(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.79.9 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -6446,7 +6446,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.79.8': + '@earendil-works/pi-tui@0.79.9': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 @@ -7167,13 +7167,13 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 - '@opencode-ai/plugin@1.17.8': + '@opencode-ai/plugin@1.17.9': dependencies: - '@opencode-ai/sdk': 1.17.8 + '@opencode-ai/sdk': 1.17.9 effect: 4.0.0-beta.74 zod: 4.1.8 - '@opencode-ai/sdk@1.17.8': + '@opencode-ai/sdk@1.17.9': dependencies: cross-spawn: 7.0.6 @@ -7932,13 +7932,13 @@ snapshots: dependencies: '@aws-sdk/credential-providers': 3.1073.0 '@cloudflare/unenv-preset': 2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260617.1) - '@cloudflare/workers-types': 4.20260620.1 + '@cloudflare/workers-types': 4.20260621.1 '@iarna/toml': 2.2.5 '@octokit/rest': 21.1.1 '@smithy/node-config-provider': 4.5.1 '@smithy/types': 4.15.0 aws4fetch: 1.0.20 - drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260620.1)(@opentelemetry/api@1.9.0) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260621.1)(@opentelemetry/api@1.9.0) env-paths: 3.0.0 esbuild: 0.25.12 execa: 9.6.1 @@ -7957,7 +7957,7 @@ snapshots: proper-lockfile: 4.1.2 signal-exit: 4.1.0 unenv: 2.0.0-rc.21 - wrangler: 4.103.0(@cloudflare/workers-types@4.20260620.1) + wrangler: 4.103.0(@cloudflare/workers-types@4.20260621.1) ws: 8.21.0 yaml: 2.9.0 optionalDependencies: @@ -8403,9 +8403,9 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260620.1)(@opentelemetry/api@1.9.0): + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260621.1)(@opentelemetry/api@1.9.0): optionalDependencies: - '@cloudflare/workers-types': 4.20260620.1 + '@cloudflare/workers-types': 4.20260621.1 '@opentelemetry/api': 1.9.0 dset@3.1.4: {} @@ -8434,7 +8434,7 @@ snapshots: msgpackr: 2.0.4 multipasta: 0.2.7 toml: 4.1.1 - uuid: 14.0.0 + uuid: 14.0.1 yaml: 2.9.0 emmet@2.4.11: @@ -8734,14 +8734,14 @@ snapshots: fast-xml-builder@1.2.0: dependencies: - path-expression-matcher: 1.5.0 + path-expression-matcher: 1.6.0 xml-naming: 0.1.0 fast-xml-parser@5.7.3: dependencies: '@nodable/entities': 2.2.0 fast-xml-builder: 1.2.0 - path-expression-matcher: 1.5.0 + path-expression-matcher: 1.6.0 strnum: 2.4.1 fast-xml-parser@5.9.3: @@ -8749,7 +8749,7 @@ snapshots: '@nodable/entities': 2.2.0 fast-xml-builder: 1.2.0 is-unsafe: 1.0.1 - path-expression-matcher: 1.5.0 + path-expression-matcher: 1.6.0 strnum: 2.4.1 xml-naming: 0.1.0 @@ -9412,7 +9412,7 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - lint-staged@17.0.7: + lint-staged@17.0.8: dependencies: listr2: 10.2.1 picomatch: 4.0.4 @@ -10015,7 +10015,7 @@ snapshots: multipasta@0.2.7: {} - nanoid@3.3.13: {} + nanoid@3.3.14: {} negotiator@1.0.0: {} @@ -10238,7 +10238,7 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.5.0: {} + path-expression-matcher@1.6.0: {} path-key@3.1.1: {} @@ -10286,7 +10286,7 @@ snapshots: postcss@8.5.15: dependencies: - nanoid: 3.3.13 + nanoid: 3.3.14 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -11099,7 +11099,7 @@ snapshots: util-deprecate@1.0.2: {} - uuid@14.0.0: {} + uuid@14.0.1: {} vary@1.1.2: {} @@ -11320,7 +11320,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260617.1 '@cloudflare/workerd-windows-64': 1.20260617.1 - wrangler@4.103.0(@cloudflare/workers-types@4.20260620.1): + wrangler@4.103.0(@cloudflare/workers-types@4.20260621.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1) @@ -11331,7 +11331,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260617.1 optionalDependencies: - '@cloudflare/workers-types': 4.20260620.1 + '@cloudflare/workers-types': 4.20260621.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil diff --git a/scripts/check-public-docs.ts b/scripts/check-public-docs.ts index d0b4b130..7bc23829 100644 --- a/scripts/check-public-docs.ts +++ b/scripts/check-public-docs.ts @@ -17,14 +17,12 @@ const requiredPages = [ "agent-integrations.mdx", "remote-attach.mdx", "troubleshooting.mdx", - "changelog.mdx", "reference/config.mdx", "reference/code-mode-api.mdx", "reference/caplet-files.mdx", ]; const generatedPages = [ - "changelog.mdx", "reference/config.mdx", "reference/code-mode-api.mdx", "reference/caplet-files.mdx", @@ -49,10 +47,15 @@ const requiredContent = new Map([ ["agent-integrations.mdx", ["Codex", "Claude", "OpenCode", "Pi"]], ["remote-attach.mdx", ["caplets attach", "caplets remote login"]], ["troubleshooting.mdx", ["caplets doctor", "CAPLETS_CONFIG"]], - ["changelog.mdx", ["Changelog", "GitHub releases"]], - ["reference/config.mdx", ["https://caplets.dev/config.schema.json", "Required"]], + [ + "reference/config.mdx", + ["https://caplets.dev/config.schema.json", "Required", "googleDiscoveryApis"], + ], ["reference/code-mode-api.mdx", ["CapletHandle", "DebugApi", "CapletsResult"]], - ["reference/caplet-files.mdx", ["https://caplets.dev/caplet.schema.json", "CAPLET.md"]], + [ + "reference/caplet-files.mdx", + ["https://caplets.dev/caplet.schema.json", "CAPLET.md", "googleDiscoveryApi"], + ], ]); const forbiddenPatterns = [ diff --git a/scripts/generate-docs-reference.ts b/scripts/generate-docs-reference.ts index b029d9a8..69c30ee7 100644 --- a/scripts/generate-docs-reference.ts +++ b/scripts/generate-docs-reference.ts @@ -76,7 +76,6 @@ const outputs = new Map([ ), ], ["apps/docs/src/content/docs/reference/code-mode-api.mdx", formatMdx(codeModeApiPage())], - ["apps/docs/src/content/docs/changelog.mdx", formatMdx(changelogPage())], ]); if (process.argv.includes("--check")) { @@ -129,6 +128,7 @@ function schemaPage({ "options", "mcpServers", "openapiEndpoints", + "googleDiscoveryApis", "graphqlEndpoints", "httpApis", "cliTools", @@ -138,6 +138,7 @@ function schemaPage({ "runtime", "mcpServer", "openapiEndpoint", + "googleDiscoveryApi", "graphqlEndpoint", "httpApi", "capletSet", @@ -218,9 +219,9 @@ function commonSchemaRecipes(sourcePath: string): string { "```", "", "Keep `options.exposure` at the default `code_mode` unless your client cannot run Code", - "Mode. Add backend maps such as `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`,", - "`httpApis`, `cliTools`, or `capletSets` only for the capability sources you actually", - "want agents to see.", + "Mode. Add backend maps such as `mcpServers`, `openapiEndpoints`,", + "`googleDiscoveryApis`, `graphqlEndpoints`, `httpApis`, `cliTools`, or `capletSets` only", + "for the capability sources you actually want agents to see.", "", "Stdio MCP server:", "", @@ -477,19 +478,6 @@ ${blocks.map((block) => `\`\`\`ts\n${block}\n\`\`\``).join("\n\n")} `; } -function changelogPage(): string { - const changelog = readFileSync(join(repoRoot, "CHANGELOG.md"), "utf8").trim(); - const body = changelog.replace(/^# caplets\s*/u, "").trim(); - - return `${frontmatter("Changelog", "Public release notes for Caplets.")} -${generatedMarkerComment} - -GitHub releases: [github.com/spiritledsoftware/caplets/releases](https://github.com/spiritledsoftware/caplets/releases) - -${body} -`; -} - function extractDeclaration(source: string, declaration: string): string | undefined { const match = new RegExp(`(^|\\n)${escapeRegExp(declaration)}(?=[<\\s=])`).exec(source); if (!match) return undefined;