Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/friendly-windows-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@caplets/core": patch
"caplets": patch
---

Respect remote Caplet shadowing policy when merging local overlays into remote CLI list output.
6 changes: 6 additions & 0 deletions packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +4071 to +4075

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor remote shadowing for remote-mode execution

When a remote Caplet returns shadowing: "forbid" and a same-ID enabled local overlay exists, this branch hides the local row and tells the user it was suppressed, but remote-mode commands still call executeLocalOperation whenever hasEnabledCaplet(localOverlay.config, caplet) is true in executeOperation. After caplets list shows the remote Caplet, commands like caplets get-tool shared... or caplets call-tool shared... will still execute the local overlay, so the list output is inconsistent and the remote policy is not actually enforced.

Useful? React with 👍 / 👎.

Comment on lines +4071 to +4075

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let enabled locals override disabled remotes

When caplets list --all is used, the remote response includes disabled Caplets; if one of those disabled remote rows shares an ID with an enabled local overlay, this check still treats the disabled remote as forbidding shadowing and suppresses the local row. That makes --all show only the disabled remote Caplet even though disabled Caplets are omitted from normal discovery/execution, while caplets list without --all would show the enabled local overlay.

Useful? React with 👍 / 👎.

}
Comment on lines +4071 to +4076

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The error message fires for both remote.shadowing === "forbid" and remote.shadowing === undefined. When the remote simply omits the field (e.g. an older server that predates this policy), the message tells the user "the remote Caplet forbids shadowing" — but the remote expressed no opinion at all; the client is applying a default. A user debugging a stale server would find no "forbid" setting anywhere and get confused. Distinguishing the two cases makes the diagnostic actionable.

Suggested change
if (remote.shadowing !== "allow") {
options.writeErr(
`Local Caplet '${row.server}' is suppressed because the remote Caplet forbids shadowing that Caplet ID.\n`,
);
continue;
}
if (remote.shadowing !== "allow") {
const reason =
remote.shadowing === "forbid"
? "the remote Caplet forbids shadowing that Caplet ID"
: "the remote Caplet does not allow shadowing (no shadowing policy set)";
options.writeErr(`Local Caplet '${row.server}' is suppressed because ${reason}.\n`);
continue;
}

Fix in Codex

options.writeErr(
`Warning: ${formatOverlaySource(row.source)} Caplet ${row.server} shadows remote Caplet\n`,
);
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/cli/inspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
resolveConfigPath,
resolveProjectConfigPath,
type CapletConfig,
type CapletShadowingPolicy,
type CapletsConfig,
type ConfigSource,
type ConfigWithSources,
Expand All @@ -21,6 +22,7 @@ type CapletListRow = {
source: ConfigSource["kind"] | "remote" | "unknown";
path: string | null;
shadows: ConfigSource[];
shadowing?: CapletShadowingPolicy | undefined;
};

type ConfigPaths = {
Expand Down Expand Up @@ -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));
}
Expand Down
43 changes: 40 additions & 3 deletions packages/core/test/cli-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"], {
Expand All @@ -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");
});
Comment on lines +515 to +541

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test for undefined shadowing with an active local overlay. The suite now covers explicit "forbid" and explicit "allow", but not the case where the remote row omits shadowing entirely (e.g. an older self-hosted server). Per the !== "allow" guard, an omitted field silently suppresses the local overlay — the same outcome as "forbid" but with a different diagnostic message. A test using a plain remoteListRow(...) spread (no shadowing) alongside an active local overlay would pin this default-forbid behaviour and guard against any future accidental loosening.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex


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"], {
Expand All @@ -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"], {
Expand Down
17 changes: 17 additions & 0 deletions packages/core/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down