From d4bf36dfd6a726fd369401d75a7ff903de0b7604 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 23 Jun 2026 08:19:13 -0400 Subject: [PATCH 1/6] fix(cli): respect remote shadowing policy in list --- .changeset/friendly-windows-heal.md | 6 ++++ packages/core/src/cli.ts | 6 ++++ packages/core/src/cli/inspection.ts | 3 ++ packages/core/test/cli-remote.test.ts | 43 +++++++++++++++++++++++++-- packages/core/test/config.test.ts | 17 +++++++++++ 5 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 .changeset/friendly-windows-heal.md diff --git a/.changeset/friendly-windows-heal.md b/.changeset/friendly-windows-heal.md new file mode 100644 index 00000000..6daf52ad --- /dev/null +++ b/.changeset/friendly-windows-heal.md @@ -0,0 +1,6 @@ +--- +"@caplets/core": patch +"caplets": patch +--- + +Respect remote Caplet shadowing policy when merging local overlays into remote CLI list output. diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 5147f81a..907038e0 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -4068,6 +4068,12 @@ function mergeRemoteAndLocalRows( if (row.disabled) { continue; } + if (remote.shadowing !== "allow") { + options.writeErr( + `Local Caplet '${row.server}' is suppressed because the remote Caplet forbids shadowing that Caplet ID.\n`, + ); + continue; + } options.writeErr( `Warning: ${formatOverlaySource(row.source)} Caplet ${row.server} shadows remote Caplet\n`, ); diff --git a/packages/core/src/cli/inspection.ts b/packages/core/src/cli/inspection.ts index 55c53954..7642fd7b 100644 --- a/packages/core/src/cli/inspection.ts +++ b/packages/core/src/cli/inspection.ts @@ -5,6 +5,7 @@ import { resolveConfigPath, resolveProjectConfigPath, type CapletConfig, + type CapletShadowingPolicy, type CapletsConfig, type ConfigSource, type ConfigWithSources, @@ -21,6 +22,7 @@ type CapletListRow = { source: ConfigSource["kind"] | "remote" | "unknown"; path: string | null; shadows: ConfigSource[]; + shadowing?: CapletShadowingPolicy | undefined; }; type ConfigPaths = { @@ -50,6 +52,7 @@ export function listCaplets( source: sources[server.server]?.kind ?? "unknown", path: sources[server.server]?.path ?? null, shadows: shadows[server.server] ?? [], + shadowing: server.shadowing, })); return rows.sort((left, right) => left.server.localeCompare(right.server)); } diff --git a/packages/core/test/cli-remote.test.ts b/packages/core/test/cli-remote.test.ts index 96a60faa..1c0fb6a7 100644 --- a/packages/core/test/cli-remote.test.ts +++ b/packages/core/test/cli-remote.test.ts @@ -493,7 +493,10 @@ describe("remote CLI routing", () => { const err: string[] = []; writeCliCapletConfig(context.configPath, "shared", "Disabled Shared", { disabled: true }); const fetch = vi.fn(async () => - Response.json({ ok: true, result: [remoteListRow("shared", "Remote Shared")] }), + Response.json({ + ok: true, + result: [{ ...remoteListRow("shared", "Remote Shared"), shadowing: "allow" }], + }), ); await runCli(["list", "--json"], { @@ -509,13 +512,44 @@ describe("remote CLI routing", () => { expect(err.join("")).not.toContain("shadows remote Caplet"); }); + it("keeps remote list rows when the remote Caplet forbids local shadowing", async () => { + const context = testContext("caplets-cli-remote-list-forbid-shadow-"); + const out: string[] = []; + const err: string[] = []; + writeCliCapletConfig(context.configPath, "shared", "Local Shared"); + const fetch = vi.fn(async () => + Response.json({ + ok: true, + result: [{ ...remoteListRow("shared", "Remote Shared"), shadowing: "forbid" }], + }), + ); + + await runCli(["list", "--json"], { + env: remoteEnv(context), + fetch, + writeOut: (value) => out.push(value), + writeErr: (value) => err.push(value), + }); + + expect(JSON.parse(out.join(""))).toEqual([ + expect.objectContaining({ server: "shared", name: "Remote Shared", source: "remote" }), + ]); + expect(err.join("")).toContain( + "Local Caplet 'shared' is suppressed because the remote Caplet forbids shadowing that Caplet ID.", + ); + expect(err.join("")).not.toContain("global Caplet shared shadows remote Caplet"); + }); + it("lets project list rows shadow remote rows and warns on stderr", async () => { const context = testContext("caplets-cli-remote-list-project-shadow-"); const out: string[] = []; const err: string[] = []; writeProjectMcpCaplet(context.projectCapletsRoot, "shared", "Project Shared"); const fetch = vi.fn(async () => - Response.json({ ok: true, result: [remoteListRow("shared", "Remote Shared")] }), + Response.json({ + ok: true, + result: [{ ...remoteListRow("shared", "Remote Shared"), shadowing: "allow" }], + }), ); await runCli(["list", "--json"], { @@ -537,7 +571,10 @@ describe("remote CLI routing", () => { const err: string[] = []; writeCliCapletConfig(context.configPath, "shared", "Global Shared"); const fetch = vi.fn(async () => - Response.json({ ok: true, result: [remoteListRow("shared", "Remote Shared")] }), + Response.json({ + ok: true, + result: [{ ...remoteListRow("shared", "Remote Shared"), shadowing: "allow" }], + }), ); await runCli(["list", "--json"], { diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 8cf29abe..a6d056b5 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -3140,6 +3140,23 @@ describe("config", () => { ]); }); + it("includes Caplet shadowing policy in inspection list rows", () => { + const config = parseConfig({ + mcpServers: { + browser: { + name: "Browser", + description: "Local browser tools.", + command: "browser-mcp", + shadowing: "allow", + }, + }, + }); + + expect(listCaplets({ config, sources: {}, shadows: {} }, { includeDisabled: false })).toEqual([ + expect.objectContaining({ server: "browser", shadowing: "allow" }), + ]); + }); + it("rejects invalid Caplet set sources and duplicate IDs", () => { expect(() => parseConfig({ From e86c05066fefe2cfc7c7d83058af25ba1fe17c68 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 23 Jun 2026 10:36:42 -0400 Subject: [PATCH 2/6] feat(core): add namespace shadowing policy --- .changeset/tidy-namespace-shadowing.md | 5 + .gitignore | 3 + CONCEPTS.md | 6 + .../content/docs/reference/caplet-files.mdx | 2 +- .../src/content/docs/reference/config.mdx | 45 +-- apps/landing/public/caplet.schema.json | 2 +- apps/landing/public/config.schema.json | 43 +- ...namespace-shadowing-policy-requirements.md | 146 +++++++ ...01-feat-namespace-shadowing-policy-plan.md | 370 ++++++++++++++++++ packages/core/src/attach/api.ts | 2 +- packages/core/src/caplet-files-bundle.ts | 2 +- packages/core/src/code-mode/types.ts | 2 +- packages/core/src/config-runtime.ts | 52 ++- packages/core/src/config.ts | 77 +++- packages/core/src/config/validation.ts | 5 + packages/core/src/errors.ts | 1 + packages/core/src/exposure/namespace.ts | 265 +++++++++++++ packages/core/src/native/remote.ts | 21 +- packages/core/src/native/service.ts | 227 +++++++++-- packages/core/test/attach-api.test.ts | 12 +- packages/core/test/caplet-files.test.ts | 26 ++ packages/core/test/caplet-source.test.ts | 4 +- packages/core/test/config.test.ts | 117 +++++- packages/core/test/exposure-discovery.test.ts | 1 + packages/core/test/exposure-namespace.test.ts | 167 ++++++++ packages/core/test/native-remote.test.ts | 60 +++ packages/core/test/openapi.test.ts | 1 + schemas/caplet.schema.json | 2 +- schemas/caplets-config.schema.json | 43 +- 29 files changed, 1613 insertions(+), 96 deletions(-) create mode 100644 .changeset/tidy-namespace-shadowing.md create mode 100644 docs/brainstorms/2026-06-23-namespace-shadowing-policy-requirements.md create mode 100644 docs/plans/2026-06-23-001-feat-namespace-shadowing-policy-plan.md create mode 100644 packages/core/src/exposure/namespace.ts create mode 100644 packages/core/test/exposure-namespace.test.ts diff --git a/.changeset/tidy-namespace-shadowing.md b/.changeset/tidy-namespace-shadowing.md new file mode 100644 index 00000000..dfe9e88d --- /dev/null +++ b/.changeset/tidy-namespace-shadowing.md @@ -0,0 +1,5 @@ +--- +"@caplets/core": minor +--- + +Add namespace shadowing policy support with source-level aliases and native remote/local qualified IDs. diff --git a/.gitignore b/.gitignore index 7cc9f8bc..c09ce3a0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ benchmark-results/ # worktrees .worktrees/ + +# brag +brag-output/ diff --git a/CONCEPTS.md b/CONCEPTS.md index b0ae0238..d3607cd3 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -8,6 +8,12 @@ Shared domain vocabulary for this project -- entities, named processes, and stat A configured capability surface that exposes a backend to agents through a stable handle, progressive wrapper tools, or direct tool operations. +### Namespace Shadowing Policy + +A Caplet shadowing policy where a local/upstream ID collision exposes both Caplets under qualified namespace IDs and removes the ambiguous bare ID. + +Namespace Shadowing is collision-only: non-colliding Caplets keep their normal IDs, while colliding Caplets use explicit, hash-suffixed runtime labels such as `remote-a1b2` and `local-9f3c` before the double-underscore Caplet ID separator. Runtime labels may have configured aliases, but aliases replace the namespace label instead of creating duplicate handles. Hash suffixes must be small, stable, and derived from durable source identity; unresolved generated-ID collisions fail closed with diagnostics rather than falling back to forbidden shadowing behavior. + ### Caplets Daemon A per-user native service managed by `caplets daemon` that runs local HTTP `caplets serve` through the operating system service manager. diff --git a/apps/docs/src/content/docs/reference/caplet-files.mdx b/apps/docs/src/content/docs/reference/caplet-files.mdx index dd57d29e..81fd2267 100644 --- a/apps/docs/src/content/docs/reference/caplet-files.mdx +++ b/apps/docs/src/content/docs/reference/caplet-files.mdx @@ -65,7 +65,7 @@ Use this Caplet when an agent needs the current repository's local test signal. | `description` | Required | string | Compact capability description shown before the full Caplet card is disclosed. | | `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. | +| `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | 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 explicit setup and verification metadata for this Caplet. | diff --git a/apps/docs/src/content/docs/reference/config.mdx b/apps/docs/src/content/docs/reference/config.mdx index 43eecf43..caf5c7ba 100644 --- a/apps/docs/src/content/docs/reference/config.mdx +++ b/apps/docs/src/content/docs/reference/config.mdx @@ -62,21 +62,22 @@ Public OpenAPI endpoint: ## Top-level fields -| Field | Status | Type | Description | -| --------------------- | -------- | ------- | ------------------------------------------------------ | -| `$schema` | Optional | string | Optional JSON Schema for editor validation. | -| `version` | Optional | number | Caplets config schema version. | -| `defaultSearchLimit` | Optional | integer | Default maximum number of same-server search results. | -| `maxSearchLimit` | Optional | integer | Maximum accepted search_tools limit. | -| `completion` | Optional | object | Shell completion discovery timeout and cache settings. | -| `options` | Optional | object | Global Caplets runtime options. | -| `mcpServers` | Optional | object | Downstream MCP servers keyed by stable server ID. | -| `openapiEndpoints` | Optional | object | OpenAPI endpoints keyed by stable Caplet ID. | -| `googleDiscoveryApis` | Optional | object | Google Discovery APIs keyed by stable Caplet ID. | -| `graphqlEndpoints` | Optional | object | GraphQL endpoints keyed by stable Caplet ID. | -| `httpApis` | Optional | object | HTTP APIs keyed by stable Caplet ID. | -| `cliTools` | Optional | object | CLI tools keyed by stable Caplet ID. | -| `capletSets` | Optional | object | Nested Caplet collections keyed by stable Caplet ID. | +| Field | Status | Type | Description | +| --------------------- | -------- | ------- | ------------------------------------------------------------- | +| `$schema` | Optional | string | Optional JSON Schema for editor validation. | +| `version` | Optional | number | Caplets config schema version. | +| `defaultSearchLimit` | Optional | integer | Default maximum number of same-server search results. | +| `maxSearchLimit` | Optional | integer | Maximum accepted search_tools limit. | +| `completion` | Optional | object | Shell completion discovery timeout and cache settings. | +| `options` | Optional | object | Global Caplets runtime options. | +| `namespaceAliases` | Optional | object | Source-level namespace aliases for hash-qualified Caplet IDs. | +| `mcpServers` | Optional | object | Downstream MCP servers keyed by stable server ID. | +| `openapiEndpoints` | Optional | object | OpenAPI endpoints keyed by stable Caplet ID. | +| `googleDiscoveryApis` | Optional | object | Google Discovery APIs keyed by stable Caplet ID. | +| `graphqlEndpoints` | Optional | object | GraphQL endpoints keyed by stable Caplet ID. | +| `httpApis` | Optional | object | HTTP APIs keyed by stable Caplet ID. | +| `cliTools` | Optional | object | CLI tools keyed by stable Caplet ID. | +| `capletSets` | Optional | object | Nested Caplet collections keyed by stable Caplet ID. | ## Major sections @@ -118,7 +119,7 @@ Downstream MCP servers keyed by stable server ID. | `auth` | Optional | object | Authentication settings for a remote MCP server. | | `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. | +| `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | 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. | @@ -143,7 +144,7 @@ OpenAPI endpoints keyed by stable Caplet ID. | `auth` | Required | object | Explicit OpenAPI 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. | +| `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | 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. | @@ -169,7 +170,7 @@ Google Discovery APIs keyed by stable Caplet ID. | `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. | +| `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | 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. | @@ -195,7 +196,7 @@ GraphQL endpoints keyed by stable Caplet ID. | `auth` | Required | object | Explicit GraphQL 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. | +| `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | 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. | @@ -219,7 +220,7 @@ HTTP APIs keyed by stable Caplet ID. | `actions` | Required | object | Configured HTTP actions keyed by stable tool name. | | `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. | +| `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | 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. | @@ -242,7 +243,7 @@ CLI tools keyed by stable Caplet ID. | `env` | Optional | object | Default environment variables for CLI actions. | | `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. | +| `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | 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. | @@ -267,7 +268,7 @@ Nested Caplet collections keyed by stable Caplet ID. | `toolCacheTtlMs` | Optional | integer | Milliseconds child Caplet metadata stays fresh. Set 0 to refresh every time. | | `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. | +| `shadowing` | Optional | "forbid" \| "allow" \| "namespace" | 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. | diff --git a/apps/landing/public/caplet.schema.json b/apps/landing/public/caplet.schema.json index 73f6898d..ea20ee26 100644 --- a/apps/landing/public/caplet.schema.json +++ b/apps/landing/public/caplet.schema.json @@ -41,7 +41,7 @@ }, "shadowing": { "type": "string", - "enum": ["forbid", "allow"], + "enum": ["forbid", "allow", "namespace"], "description": "Whether attached local Caplets may shadow this remote Caplet ID." }, "useWhen": { diff --git a/apps/landing/public/config.schema.json b/apps/landing/public/config.schema.json index 9a6d84be..90021fe3 100644 --- a/apps/landing/public/config.schema.json +++ b/apps/landing/public/config.schema.json @@ -102,6 +102,35 @@ }, "additionalProperties": false }, + "namespaceAliases": { + "default": { + "upstreams": {} + }, + "description": "Source-level namespace aliases for hash-qualified Caplet IDs.", + "type": "object", + "properties": { + "local": { + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]{0,30}[a-z0-9])?$", + "description": "Namespace label used when qualifying colliding Caplet IDs." + }, + "upstreams": { + "default": {}, + "description": "Namespace aliases keyed by durable upstream source identity.", + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]{0,30}[a-z0-9])?$", + "description": "Namespace label used when qualifying colliding Caplet IDs." + } + } + }, + "additionalProperties": false + }, "mcpServers": { "default": {}, "description": "Downstream MCP servers keyed by stable server ID.", @@ -351,7 +380,7 @@ "default": "forbid", "description": "Whether attached local Caplets may shadow this remote Caplet ID.", "type": "string", - "enum": ["forbid", "allow"] + "enum": ["forbid", "allow", "namespace"] }, "useWhen": { "description": "When agents should prefer this Caplet or configured action.", @@ -773,7 +802,7 @@ "default": "forbid", "description": "Whether attached local Caplets may shadow this remote Caplet ID.", "type": "string", - "enum": ["forbid", "allow"] + "enum": ["forbid", "allow", "namespace"] }, "useWhen": { "description": "When agents should prefer this Caplet or configured action.", @@ -1204,7 +1233,7 @@ "default": "forbid", "description": "Whether attached local Caplets may shadow this remote Caplet ID.", "type": "string", - "enum": ["forbid", "allow"] + "enum": ["forbid", "allow", "namespace"] }, "useWhen": { "description": "When agents should prefer this Caplet or configured action.", @@ -1670,7 +1699,7 @@ "default": "forbid", "description": "Whether attached local Caplets may shadow this remote Caplet ID.", "type": "string", - "enum": ["forbid", "allow"] + "enum": ["forbid", "allow", "namespace"] }, "useWhen": { "description": "When agents should prefer this Caplet or configured action.", @@ -2186,7 +2215,7 @@ "default": "forbid", "description": "Whether attached local Caplets may shadow this remote Caplet ID.", "type": "string", - "enum": ["forbid", "allow"] + "enum": ["forbid", "allow", "namespace"] }, "useWhen": { "description": "When agents should prefer this Caplet or configured action.", @@ -2551,7 +2580,7 @@ "default": "forbid", "description": "Whether attached local Caplets may shadow this remote Caplet ID.", "type": "string", - "enum": ["forbid", "allow"] + "enum": ["forbid", "allow", "namespace"] }, "useWhen": { "description": "When agents should prefer this Caplet or configured action.", @@ -2814,7 +2843,7 @@ "default": "forbid", "description": "Whether attached local Caplets may shadow this remote Caplet ID.", "type": "string", - "enum": ["forbid", "allow"] + "enum": ["forbid", "allow", "namespace"] }, "useWhen": { "description": "When agents should prefer this Caplet or configured action.", diff --git a/docs/brainstorms/2026-06-23-namespace-shadowing-policy-requirements.md b/docs/brainstorms/2026-06-23-namespace-shadowing-policy-requirements.md new file mode 100644 index 00000000..a90701db --- /dev/null +++ b/docs/brainstorms/2026-06-23-namespace-shadowing-policy-requirements.md @@ -0,0 +1,146 @@ +--- +date: 2026-06-23 +topic: namespace-shadowing-policy +--- + +# Namespace Shadowing Policy Requirements + +## Summary + +Add `shadowing: namespace` as a third Caplet shadowing policy. When two or more participating sources share the same base Caplet ID under this policy, Caplets exposes each side under explicit hash-suffixed, double-underscore qualified IDs and removes the ambiguous bare ID across direct, progressive, Code Mode, and mixed exposure modes. + +--- + +## Problem Frame + +Remote Attach already has a policy boundary for local overlays: a remote Caplet can forbid local shadowing or allow a local Caplet to replace the matching remote one. That binary choice is too coarse for users who intentionally keep matching local and remote Caplets, such as browser or computer-use capabilities that are useful both on the local machine and on a remote host. + +The current collision model forces one side to win or one side to disappear. The desired behavior is side-by-side access without making agents guess which runtime a bare Caplet ID means. + +--- + +## Key Decisions + +- **Namespace is collision-only.** Non-colliding Caplets keep their existing IDs so normal remote usage does not churn. +- **No bare ID survives a namespace collision.** Removing the bare ID is the clearest way to avoid a hidden local-vs-remote default. +- **Qualified IDs use hash-suffixed namespace labels before the double underscore.** Examples such as `remote-a1b2__github` and `local-9f3c__github` avoid dot-target ambiguity, align with existing native tool naming conventions, and reduce generated-ID collision risk. +- **Default labels come first, with configured aliases in scope.** The first version should work with `remote` and `local` labels by default and allow users to configure namespace labels like `vps` or `mac`; planning decides the config shape. +- **Hash suffixes must be small and stable.** The suffix must come from durable source identity, must not change across normal restarts or unrelated config edits, and must fail closed with a diagnostic when Caplets cannot determine a durable identity. +- **Multiple upstreams are supported.** Namespace behavior applies to any same-base-ID collision among participating sources whose relevant upstream policies declare `namespace`, including upstream-vs-upstream collisions with no local Caplet. +- **The upstream policy remains authoritative.** A local overlay cannot force namespace behavior for an upstream Caplet that still declares `forbid` or `allow` in the first version. + +--- + +## Actors + +- A1. **Agent user:** Configures local and remote Caplets and expects predictable access to both runtimes. +- A2. **Coding agent:** Consumes Caplets through direct tools, progressive operations, Code Mode handles, or mixed exposure modes. +- A3. **Remote Caplets host:** Publishes upstream Caplets and their shadowing policies through attach and remote-control surfaces. +- A4. **Local overlay runtime:** Provides local Caplets that may collide with upstream Caplet IDs. + +--- + +## Requirements + +**Policy contract** + +- R1. `shadowing: namespace` must be accepted anywhere existing Caplet shadowing policy is accepted. +- R2. When two or more participating sources share the same base Caplet ID and every affected upstream Caplet declares `shadowing: namespace`, Caplets must expose each source under a qualified ID. +- R3. Namespace behavior must cover local-vs-upstream and upstream-vs-upstream base-ID collisions. +- R4. The upstream Caplet's shadowing policy must remain authoritative; local config must not force namespace behavior for upstream Caplets that declare `forbid` or `allow` in the first version. +- R5. During a namespace collision, Caplets must not expose the unqualified base ID for any colliding source. +- R6. Caplets that do not collide must keep their normal unqualified IDs. + +**Qualified identity** + +- R7. Qualified IDs must use a namespace label, a small stable hash suffix, and a double-underscore separator before the base ID, such as `remote-a1b2__github` and `local-9f3c__github`. +- R8. The default namespace labels must be predictable without additional setup. +- R9. Users must be able to configure friendly namespace aliases for local and upstream namespaces, such as `vps-a1b2__github` or `mac-9f3c__github`. +- R10. Configured aliases replace the namespace label for generated qualified IDs rather than creating duplicate handles. +- R11. Hash suffixes must be derived from durable source identity and must not change across normal restarts or unrelated config edits. +- R12. Namespace labels and generated qualified IDs must be validated so they do not collide with existing Caplet IDs or each other. +- R13. If Caplets cannot determine a durable source identity, or if generated qualified IDs still collide after suffixing, namespace exposure must fail closed with a diagnostic rather than falling back to `forbid` behavior. +- R14. Namespace labels must support multiple upstream sources in the same exposure context. + +**Exposure coverage** + +- R15. Namespace collision behavior must apply consistently to direct exposure, progressive exposure, Code Mode, and mixed exposure modes. +- R16. Generated Code Mode handles must use the same resolved qualified Caplet IDs that other surfaces expose. +- R17. Native direct tool names must remain unambiguous when a qualified Caplet ID is used. +- R18. CLI inspection, listing, completion, and execution must make the same qualified IDs available that the runtime can execute. + +**User feedback and compatibility** + +- R19. When a bare ID is removed because of a namespace collision, Caplets must present enough diagnostic information for users to discover the qualified alternatives. +- R20. Existing `forbid` and `allow` behavior must remain unchanged. +- R21. Existing non-colliding Caplet IDs must remain stable. + +--- + +## Collision Model + +```mermaid +flowchart TB + A[Remote Caplet: github] --> C{Any source with same ID?} + B[Local or another upstream Caplet: github] --> C + C -->|no| D[Expose github] + C -->|yes and shadowing: forbid| E[Expose existing forbid behavior] + C -->|yes and shadowing: allow| F[Expose existing allow behavior] + C -->|yes and shadowing: namespace| G[Expose remote-a1b2__github and local-9f3c__github] + G --> H[Do not expose bare github] +``` + +The diagram is conceptual. It describes user-visible identity outcomes, not an implementation sequence. + +--- + +## Acceptance Examples + +- AE1. **Covers R2, R5, R7, R15.** Given remote `github` has `shadowing: namespace` and local `github` exists, when Caplets exposes capabilities in any supported mode, then `remote-a1b2__github` and `local-9f3c__github` are available and `github` is not. +- AE2. **Covers R6, R21.** Given remote `linear` has no local or upstream collision, when Caplets lists or exposes capabilities, then `linear` remains the visible ID. +- AE3. **Covers R18, R19.** Given a user tries to inspect or execute bare `github` during a namespace collision, then Caplets points the user toward the generated qualified IDs rather than silently choosing one. +- AE4. **Covers R9, R10, R12.** Given the upstream namespace is configured with alias `vps`, when remote `github` collides with local `github`, then the remote qualified ID is `vps-a1b2__github` and no separate `remote-a1b2__github` alias is created for that same Caplet. +- AE5. **Covers R9, R10, R12.** Given the local namespace is configured with alias `mac`, when remote `github` collides with local `github`, then the local qualified ID is `mac-9f3c__github` and no separate `local-9f3c__github` alias is created for that same Caplet. +- AE6. **Covers R3, R14.** Given two upstream sources both publish `github`, both affected upstream Caplets declare `shadowing: namespace`, and no local `github` exists, when both upstreams participate in one exposure context, then both upstream Caplets are exposed under distinct hash-suffixed qualified IDs and `github` is not. +- AE7. **Covers R11, R13.** Given Caplets cannot determine a durable source identity for a colliding namespace source, when namespace exposure is resolved, then Caplets fails closed with a diagnostic rather than exposing a bare ID or falling back to `forbid` behavior. +- AE8. **Covers R20.** Given remote `github` uses `shadowing: forbid`, when local `github` exists, then existing suppression behavior remains in force. +- AE9. **Covers R20.** Given remote `github` uses `shadowing: allow`, when local `github` exists, then existing local-wins behavior remains in force. + +--- + +## Scope Boundaries + +- Namespacing non-colliding Caplets is out of scope for the first version. +- Keeping a bare-ID alias during a namespace collision is out of scope. +- Reframing all remote/local precedence rules beyond the new `namespace` policy is out of scope. + +--- + +## Dependencies and Assumptions + +- Current policy values are only `forbid` and `allow`; adding `namespace` affects config parsing, runtime config, attach manifests, docs, generated schemas, and tests. +- Dot-qualified CLI operation targets already use `.`, so dot-based namespace IDs would be ambiguous for single-token CLI targets. +- Native tool naming already uses double underscores in names such as `caplets__`, making double-underscore qualified Caplet IDs a compatible user-facing direction. +- If the pending remote-list shadowing fix is not merged first, planning should account for CLI list and execution paths that still route by base Caplet ID. +- Configured namespace aliases are assumed to be a small extension of Caplets config, but planning must still define the exact config shape and validation surface. + +--- + +## Outstanding Questions + +### Deferred to Planning + +- What config shape should declare namespace aliases: source-level aliases, per-Caplet overrides, or both? +- What exact durable source identity, hash algorithm, and display length should be used for suffixing namespace labels? + +--- + +## Sources / Research + +- `packages/core/src/config.ts` defines the current `CapletShadowingPolicy` and common shadowing schema. +- `packages/core/src/config-runtime.ts` mirrors the runtime shadowing policy enum and default. +- `packages/core/src/attach/api.ts` carries shadowing policy into attach manifest exports. +- `packages/core/src/native/service.ts` suppresses local Caplets whose remote counterparts do not allow shadowing. +- `packages/core/src/cli.ts` parses dot-qualified CLI targets and routes remote-mode execution through local overlays by base Caplet ID. +- `packages/core/src/native/tools.ts` and `packages/core/src/exposure/direct-names.ts` show existing double-underscore native tool naming. +- `apps/docs/src/content/docs/reference/config.mdx` and `apps/docs/src/content/docs/reference/caplet-files.mdx` expose shadowing policy in generated reference docs. diff --git a/docs/plans/2026-06-23-001-feat-namespace-shadowing-policy-plan.md b/docs/plans/2026-06-23-001-feat-namespace-shadowing-policy-plan.md new file mode 100644 index 00000000..775805fe --- /dev/null +++ b/docs/plans/2026-06-23-001-feat-namespace-shadowing-policy-plan.md @@ -0,0 +1,370 @@ +--- +title: "feat: Add namespace shadowing policy" +type: feat +date: 2026-06-23 +origin: docs/brainstorms/2026-06-23-namespace-shadowing-policy-requirements.md +--- + +# feat: Add namespace shadowing policy + +## Summary + +Add `shadowing: namespace` as a third Caplet shadowing policy that exposes colliding Caplets under stable hash-suffixed qualified IDs, removes the ambiguous bare ID, and keeps non-colliding IDs unchanged across direct, progressive, Code Mode, CLI, and mixed native surfaces. + +--- + +## Problem Frame + +Remote Attach currently treats remote/local collisions as a binary policy: `forbid` suppresses the local overlay and `allow` lets the local overlay win. That is too coarse when users intentionally need both runtimes, such as local and remote browser/computer Caplets, and it creates agent-facing ambiguity when the same bare ID could mean two different execution contexts. + +--- + +## Requirements + +**Policy and identity** + +- R1. Accept `shadowing: namespace` everywhere the existing shadowing policy is parsed, serialized, documented, and exposed. +- R2. Resolve any same-base-ID collision among participating local/upstream sources into qualified IDs when every affected upstream Caplet declares `shadowing: namespace`. +- R3. Preserve existing `forbid` and `allow` behavior for non-namespace collisions. +- R4. Remove the unqualified base ID from every exposure surface during a namespace collision. +- R5. Keep non-colliding Caplet IDs unchanged. + +**Qualified IDs and aliases** + +- R6. Generate qualified IDs as `-__`. +- R7. Derive hash suffixes from durable source identity and keep them stable across normal restarts and unrelated config edits. +- R8. Support configured source-level namespace aliases for local and upstream sources; aliases replace labels rather than creating duplicate handles. +- R9. Validate namespace labels and generated IDs across each exposure context, failing closed with diagnostics when durable identity or uniqueness cannot be established. + +**Surface parity** + +- R10. Feed the same resolved identity map into MCP serve/session registration, progressive tools, direct tools/resources/prompts, Code Mode declarations/execution, native integrations, CLI list/inspect/execute/completion, and remote attach manifests. +- R11. Diagnostics for removed bare IDs must name the qualified alternatives and the reason the bare ID is unavailable. +- R12. Multiple upstream sources must be representable in the same exposure context, even when v1 commonly has one remote service plus local overlays. + +--- + +## Key Technical Decisions + +- **Centralize namespace resolution before rendering surfaces.** Add a resolver that takes normalized source entries and returns visible IDs, route metadata, suppressed bare IDs, and diagnostics. The resolver owns identity resolution and policy aggregation; adapters own only surface projection and execution through route maps. +- **Treat qualified IDs as visible IDs, not execution IDs.** The resolver records how `remote-a1b2__github` routes to the remote export and how `local-9f3c__github` routes to the local base Caplet. Existing child services should continue executing their own base/export IDs where possible. +- **Use source-level aliases in config.** Namespace aliases name sources, not individual Caplets. The plan uses a source-level alias model because the same local or remote source can collide on many Caplets and duplicate per-Caplet aliases would drift. Alias selectors bind to the same durable identities used for hashing: local config context for local sources and normalized remote profile or attach host identity for upstream sources. +- **Use a small stable hash suffix for every namespaced ID.** Use a deterministic hash of durable source identity with a fixed display length chosen before resolver implementation tests are written. Do not lengthen suffixes contextually after unrelated sources appear; if fixed-length generated IDs collide, fail closed with an actionable diagnostic. +- **Keep upstream policy authoritative.** Local config can name aliases, but it cannot convert an upstream `forbid` or `allow` collision into namespace behavior. +- **Route diagnostics as part of the identity map.** Bare-ID failures should not look like ordinary “server not found” errors when alternatives are known. Use one resolver-owned diagnostic payload with stable reason codes, alternatives, sources, and hints so CLI/native/Code Mode/direct surfaces render the same recovery information. +- **Scope uniqueness to an exposure context.** Validate generated IDs within the composed view being exposed, while deriving hash suffixes from source-global durable identity. Independent contexts should not constrain each other, but one composed view must never contain ambiguous IDs. +- **Fail closed across protocol/version gaps.** Attach manifests that carry `namespace` need explicit version/compatibility gating. Older peers or manifests that cannot represent `namespace` or source identity should produce protocol/version diagnostics rather than degrading to `forbid` or silently dropping a side. + +--- + +### Namespace policy decision table + +| Collision group | Visible outcome | Diagnostic outcome | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Local + upstream `namespace` | Qualified local and upstream IDs; no bare ID | Bare ID reports namespace alternatives | +| Local + upstream `forbid` | Existing remote-wins/local-suppressed behavior | No namespace alternatives | +| Local + upstream `allow` | Existing local-wins behavior | No namespace alternatives | +| Multiple upstreams, all `namespace` | Qualified upstream IDs; no bare ID | Bare ID reports namespace alternatives | +| Multiple upstreams with any `forbid` or `allow` | Preserve existing authoritative non-namespace behavior for the affected base ID | No namespace alternatives unless the resolver can prove a namespace-only subgroup is independent | +| Missing durable source identity or fixed-length generated-ID collision | No ambiguous qualified record is exposed | Fail-closed diagnostic names the identity or uniqueness reason | + +The resolver owns this table. Adapters render its outputs and diagnostics but do not reinterpret mixed-policy groups. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + A[Config + Caplet files] --> B[Source entries] + C[Attach manifests / remote tools] --> B + O[Alias config + source identity] --> B + D[Local overlay service] --> B + B --> E[Namespace resolver] + E --> F[Visible Caplet records] + E --> G[Route map] + E --> H[Suppressed bare-ID diagnostics] + E --> P[Fail-closed unavailable diagnostics] + F --> I[Progressive tools] + F --> J[Direct tools/resources/prompts] + F --> K[Code Mode declarations] + F --> L[CLI list/completion] + G --> M[Native and CLI execution] + H --> N[Bare-ID error recovery] + P --> N +``` + +The resolver is the identity boundary. Everything downstream consumes resolved visible records, route metadata, and diagnostic payloads; nothing downstream decides independently whether `github` means local, remote, or both. Surface adapters project this result into MCP/native tool names, direct resource URIs, Code Mode declarations, CLI rows, and completions without reconstructing IDs by string parsing. + +--- + +## Implementation Units + +### U1. Extend shadowing policy contracts + +- **Goal:** Add `namespace` to the shared policy type, runtime mirror types, Caplet-file parsing, attach manifest types, native remote types, and generated config/docs contracts. +- **Requirements:** R1, R3. +- **Dependencies:** None. +- **Files:** + - `packages/core/src/config.ts` + - `packages/core/src/config-runtime.ts` + - `packages/core/src/caplet-files.ts` + - `packages/core/src/caplet-files-bundle.ts` + - `packages/core/src/caplet-source/parse.ts` + - `packages/core/src/attach/api.ts` + - `packages/core/src/serve/session.ts` + - `packages/core/src/native/remote.ts` + - `packages/core/src/native/service.ts` + - `packages/core/src/code-mode/types.ts` + - `apps/docs/src/content/docs/reference/config.mdx` + - `apps/docs/src/content/docs/reference/caplet-files.mdx` + - `packages/core/test/config.test.ts` + - `packages/core/test/config-runtime.test.ts` + - `packages/core/test/caplet-files.test.ts` + - `packages/core/test/caplet-source.test.ts` + - `packages/core/test/attach-api.test.ts` + - `packages/core/test/serve-session.test.ts` + - `packages/core/test/code-mode-mcp.test.ts` +- **Approach:** Update the policy enum in both config layers first, then propagate the type change through attach/native/Code Mode declarations without changing behavior yet. Existing `forbid` and `allow` tests remain the regression guard. +- **Execution note:** Start test-first with parsing/serialization fixtures so later behavior units cannot silently treat `namespace` as `forbid`. +- **Patterns to follow:** Existing `allow`/`forbid` tests in `packages/core/test/config.test.ts`, `packages/core/test/caplet-files.test.ts`, and `packages/core/test/attach-api.test.ts`. +- **Test scenarios:** + - Parse `shadowing: namespace` from JSON config for every backend schema that currently accepts `shadowing`. + - Parse `shadowing: namespace` from Markdown Caplet frontmatter and Caplet source summaries. + - Emit `namespace` in attach manifests and Code Mode Caplet metadata without degrading it to `forbid`. + - Confirm `forbid` defaults and `allow` behavior remain unchanged. +- **Verification:** Schema/type/docs checks recognize `namespace`, bundled Caplet-file parser output is regenerated or verified, and focused policy parsing tests pass. + +### U2. Add namespace alias config and validation + +- **Goal:** Add source-level namespace alias configuration and validate aliases before they can affect generated qualified IDs. +- **Requirements:** R6, R8, R9. +- **Dependencies:** U1. +- **Files:** + - `packages/core/src/config.ts` + - `packages/core/src/config-runtime.ts` + - `packages/core/src/config/validation.ts` + - `packages/core/test/config-validation.test.ts` + - `packages/core/test/config.test.ts` + - `apps/docs/src/content/docs/reference/config.mdx` +- **Approach:** Add a config shape for local alias plus upstream aliases keyed by durable source selector. Decide the selector contract in this unit before resolver integration: local aliases bind to local config context, while upstream aliases bind to normalized remote profile or attach host identity. Syntactic validation runs at config load; binding validation runs when local and remote sources are composed because duplicate aliases and generated-ID collisions can depend on attached manifests. Use conservative label syntax that works in CLI targets, Code Mode bracket keys, native direct tool names, and direct resource URI hosts. +- **Patterns to follow:** Existing zod validation and generated schema patterns in `packages/core/src/config.ts`; existing config validation tests for reject-with-path behavior. +- **Test scenarios:** + - Accept local and remote source aliases that produce labels like `mac` and `vps`. + - Reject aliases containing dots or `__`. + - Reject duplicate aliases in one config context. + - Confirm aliases replace namespace labels rather than adding secondary handles. +- **Verification:** Config validation rejects unsafe alias shapes before runtime resolution and generated docs describe the source-level alias model. + +### U3. Implement namespace identity resolver + +- **Goal:** Create the shared resolver that groups source entries by base ID, decides whether namespace applies, generates stable qualified IDs, records suppressed bare IDs, and returns route metadata. +- **Requirements:** R2, R4, R5, R6, R7, R9, R12. +- **Dependencies:** U1, U2. +- **Files:** + - `packages/core/src/exposure/namespace.ts` + - `packages/core/src/exposure/policy.ts` + - `packages/core/test/exposure-namespace.test.ts` + - `packages/core/test/exposure-policy.test.ts` +- **Approach:** Define normalized source-entry input with base ID, source kind, durable source identity, label/alias, upstream policy, route payload, and exposure-context identifier. For namespace collision sets, apply a resolver-owned decision table for mixed `forbid`/`allow`/`namespace` groups, generate `